Is MP4 audio-video muxing possible on the OpenMV N6 (firmware v5.0.0)?

Hello,

I am currently using the OpenMV N6 running firmware v5.0.0, and I have a question regarding audio-video recording capabilities.

I can already record MJPEG video separately using the built-in mjpeg module, and capture raw PCM audio separately using the audio module. Both work as expected on their own.

However, I would like to combine both audio and video into a single playable MP4 (or another standard container) file directly on the N6 board, rather than doing post-processing on a computer.

I noticed that the product page states the N6 has a hardware H.264 encoder and supports MP4 recording. I also found the VC8000 H.264 driver source code in the official GitHub repository under drivers/vc8000/src/h264/. However, when I tried import h264 in my MicroPython script, the module was not found.

So I would like to ask:

  1. Is there currently a built-in MicroPython API or module to mux audio and video into a single MP4 file directly on the N6?
  2. Is the hardware H.264 encoder accessible via Python in the current v5.0.0 official firmware?
  3. If on-device MP4 muxing is not yet supported, what is the officially recommended workflow to produce a video file with synchronized audio?

Thank you very much for your help.

Hi, we will be adding support for H.264 output before the end of the year. If you want to rush ahead of us, you may edit the firmware yourself. An AI coder can probably get you most of what you need rather quickly. I am focused on other tasks right now than H.264 support.

Please see the docs for how to build the firmware. As for making H.264 work… it should be straightforward from the C API. The only thing to worry about is that you will need to inject cache cleaning operations into the VENC driver, as it doesn’t include these.

The AI coder will likely get everything right except the need to add these into the library. I added the required one for JPEG support. However, for H.264 support, you have to use different paths.

Thank you very much for your reply. I have one follow-up question:

Putting aside the H.264 encoding topic for now, I would like to know if it is possible with the current firmware to generatean MP4 file stored on the SD card by muxingseparately recorded MJPEG video and raw PCM audio together. I don’t specifically require H.264 compression; Ijust want to combine the existing MJPEG video track and PCM audio track into a single MP4 container file directly on the device. Is this functionality achievable with the current firmware?Has anyone already implemented this module?

I currently handle this merging process on my PC using FFmpeg.Is there any way to port FFmpeg to the OpenMV N6 board to complete this task directly.

My AI developer told me this cannot be done.

The N6 cannot run desktop applications, so you’d just want to create two separate files for right now and merge them on the PC.

Putting aside the H.264 encoding topic for now, I would like to know if it is possible with the current firmware to generatean MP4 file stored on the SD card by muxingseparately recorded MJPEG video and raw PCM audio together. I don’t specifically require H.264 compression; Ijust want to combine the existing MJPEG video track and PCM audio track into a single MP4 container file directly on the device. Is this functionality achievable with the current firmware?Has anyone already implemented this module?

Ask your AI to write the code to output an AVI file. AVI is Audio Video Interleave. Its job is to explicitly do this type of stuff. Then for the audio bit, you want to record the audio part as PCM audio. You should be able to record JPEG video and PCM audio in an AVI container.

Claude came up with this:

# avi_recorder.py - MJPEG + PCM audio AVI recorder for the OpenMV N6
#
# Records camera frames as Motion-JPEG video and the on-board MEMS
# microphone as signed-16-bit PCM audio, interleaved into a single
# standard RIFF/AVI file that plays in VLC, ffmpeg, QuickTime, etc.
#
# Tested against the OpenMV N6 board interface:
#   https://docs.openmv.io/v5.0.0/openmvcam/quickref/openmv-n6.html
#
# --------------------------------------------------------------------------
# Quick start
# --------------------------------------------------------------------------
#   import csi, time
#   from avi_recorder import AviWriter
#
#   cam = csi.CSI()
#   cam.reset()
#   cam.pixformat(csi.RGB565)      # writer JPEG-compresses each frame
#   cam.framesize(csi.QVGA)        # 320x240
#
#   avi = AviWriter("recording.avi", 320, 240, quality=90,
#                   audio_frequency=16000, audio_channels=1)
#
#   clock = time.clock()
#   for _ in range(300):           # ~record until you decide to stop
#       clock.tick()
#       avi.add_frame(cam.snapshot())
#
#   avi.close()                    # finalizes header + index, stops audio
#
# Or as a context manager:
#   with AviWriter("clip.avi", 320, 240) as avi:
#       for _ in range(300):
#           avi.add_frame(cam.snapshot())
#
# --------------------------------------------------------------------------
# Notes
# --------------------------------------------------------------------------
# * Video FPS is *measured* from wall-clock time between the first and last
#   frame, then written back into the header on close(). You do not need to
#   know the frame rate in advance.
# * Audio is captured asynchronously via audio.start_streaming(); buffers are
#   queued and flushed (interleaved) just before each video frame is written,
#   so A/V stays roughly in sync. Any tail audio is flushed on close().
# * add_frame() accepts either an OpenMV image.Image (RGB565/GRAYSCALE - it is
#   JPEG compressed for you) or a bytes/bytearray of an already-encoded JPEG
#   (e.g. when the sensor is configured for csi.JPEG pixformat: pass
#   img.bytearray()).
# * Only 16-bit PCM is emitted (that is what the audio module produces).
 
import struct
import time
 
try:
    import audio
except ImportError:  # allows importing the module on a board without audio
    audio = None
 
 
class AviWriter:
    # AVIMAINHEADER.dwFlags
    _AVIF_HASINDEX = 0x00000010
    _AVIF_ISINTERLEAVED = 0x00000100
    # AVIINDEXENTRY.dwFlags
    _AVIIF_KEYFRAME = 0x00000010
 
    def __init__(self, path, width, height, quality=90,
                 record_audio=True, audio_channels=1,
                 audio_frequency=16000, audio_gain_db=24):
        self.path = path
        self.width = int(width)
        self.height = int(height)
        self.quality = int(quality)
 
        self.record_audio = bool(record_audio) and (audio is not None)
        self.audio_channels = int(audio_channels)
        self.audio_frequency = int(audio_frequency)
        self.audio_gain_db = int(audio_gain_db)
        self._bits = 16
        self._block_align = self.audio_channels * (self._bits // 8)
        self._avg_bps = self.audio_frequency * self._block_align
 
        # running state
        self.frames = 0
        self._audio_bytes = 0
        self._index = []          # list of (fourcc, flags, movi_offset, size)
        self._movi_off = 0        # bytes written into the movi payload so far
        self._audio_q = []        # queued PCM buffers from the streaming cb
        self._max_jpeg = 0
        self._max_audio = 0
        self._t0 = None
        self._t1 = None
        self._closed = False
 
        # Open file and reserve the (fixed-size) preamble. It is rewritten
        # with the final rates/counts in close().
        self._f = open(path, "wb")
        self._preamble_len = self._write_preamble(0.0, 0, 0)
        self._movi_data_start = self._f.tell()
 
        if self.record_audio:
            audio.init(channels=self.audio_channels,
                       frequency=self.audio_frequency,
                       gain_db=self.audio_gain_db)
            audio.start_streaming(self._audio_cb)
 
    # ------------------------------------------------------------------ #
    # Public API
    # ------------------------------------------------------------------ #
    def add_frame(self, img, quality=None):
        """Append one video frame. `img` is an OpenMV image (compressed here)
        or bytes/bytearray of an already-encoded JPEG."""
        if self._closed:
            raise OSError("AviWriter is closed")
 
        now = time.ticks_us()
        if self._t0 is None:
            self._t0 = now
        self._t1 = now
 
        if isinstance(img, (bytes, bytearray, memoryview)):
            data = img
        else:
            q = self.quality if quality is None else int(quality)
            # compress() JPEG-encodes the frame in place and returns it.
            data = img.compress(quality=q).bytearray()
 
        # Interleave: flush audio captured up to this point, then the frame.
        self._flush_audio()
        self._write_chunk(b"00dc", data, keyframe=True)
        self.frames += 1
        n = len(data)
        if n > self._max_jpeg:
            self._max_jpeg = n
 
    def close(self):
        """Stop audio, flush, write the index, patch the header, close file."""
        if self._closed:
            return
        self._closed = True
 
        if self.record_audio:
            try:
                audio.stop_streaming()
            except Exception:
                pass
            self._flush_audio()   # drain any tail buffers
 
        self._write_index()
        self._finalize()
        self._f.close()
 
    # context manager sugar
    def __enter__(self):
        return self
 
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()
        return False
 
    # ------------------------------------------------------------------ #
    # Audio capture
    # ------------------------------------------------------------------ #
    def _audio_cb(self, pcmbuf):
        # The driver reuses pcmbuf, so copy the bytes out immediately.
        self._audio_q.append(bytes(pcmbuf))
 
    def _flush_audio(self):
        if not self._audio_q:
            return
        # Detach the queue so a concurrent callback appends to a fresh list.
        q = self._audio_q
        self._audio_q = []
        for buf in q:
            n = len(buf)
            if not n:
                continue
            self._write_chunk(b"01wb", buf, keyframe=False)
            self._audio_bytes += n
            if n > self._max_audio:
                self._max_audio = n
 
    # ------------------------------------------------------------------ #
    # RIFF chunk writing
    # ------------------------------------------------------------------ #
    def _write_chunk(self, fourcc, data, keyframe):
        n = len(data)
        # idx1 offset is relative to the 'movi' FOURCC (which sits 4 bytes
        # before the first payload chunk), hence +4.
        off = self._movi_off + 4
        f = self._f
        f.write(fourcc)
        f.write(struct.pack("<I", n))
        f.write(data)
        pad = n & 1               # chunks are word-aligned
        if pad:
            f.write(b"\x00")
        self._index.append(
            (fourcc, self._AVIIF_KEYFRAME if keyframe else 0, off, n))
        self._movi_off += 8 + n + pad
 
    def _write_index(self):
        f = self._f
        f.write(b"idx1")
        f.write(struct.pack("<I", 16 * len(self._index)))
        for fourcc, flags, off, size in self._index:
            f.write(fourcc)
            f.write(struct.pack("<III", flags, off, size))
 
    # ------------------------------------------------------------------ #
    # Header (preamble) - fixed size, written twice (placeholder + final)
    # ------------------------------------------------------------------ #
    def _finalize(self):
        file_size = self._f.tell()
        movi_list_size = 4 + self._movi_off      # 'movi' fourcc + payload
        riff_size = file_size - 8
 
        if self.frames >= 2 and self._t0 is not None:
            secs = time.ticks_diff(self._t1, self._t0) / 1000000.0
            fps = (self.frames - 1) / secs if secs > 0 else 30.0
        else:
            fps = 30.0
 
        self._f.seek(0)
        self._write_preamble(fps, movi_list_size, riff_size)
        self._f.seek(file_size)
 
    def _write_preamble(self, fps, movi_list_size, riff_size):
        hdrl = self._build_hdrl(fps)
        buf = bytearray()
        # RIFF header
        buf += b"RIFF" + struct.pack("<I", riff_size) + b"AVI "
        # hdrl LIST
        buf += b"LIST" + struct.pack("<I", len(hdrl) + 4) + b"hdrl" + hdrl
        # movi LIST header (payload follows this preamble)
        buf += b"LIST" + struct.pack("<I", movi_list_size) + b"movi"
        self._f.write(buf)
        return len(buf)
 
    def _build_hdrl(self, fps):
        streams = 2 if self.record_audio else 1
        micro = int(round(1000000.0 / fps)) if fps > 0 else 33333
        rate = int(round(fps * 1000)) if fps > 0 else 30000
        scale = 1000
        max_bytes_per_sec = (self._avg_bps +
                             int(self._max_jpeg * (fps if fps > 0 else 30)))
        suggested = max(self._max_jpeg, self._max_audio, 1)
 
        # --- avih (AVIMAINHEADER, 56 bytes body) ---
        avih_body = struct.pack(
            "<14I",
            micro,                       # dwMicroSecPerFrame
            max_bytes_per_sec,           # dwMaxBytesPerSec
            0,                           # dwPaddingGranularity
            self._AVIF_HASINDEX | self._AVIF_ISINTERLEAVED,  # dwFlags
            self.frames,                 # dwTotalFrames
            0,                           # dwInitialFrames
            streams,                     # dwStreams
            suggested,                   # dwSuggestedBufferSize
            self.width,                  # dwWidth
            self.height,                 # dwHeight
            0, 0, 0, 0)                  # dwReserved[4]
        avih = b"avih" + struct.pack("<I", len(avih_body)) + avih_body
 
        # --- video stream list (vids / MJPG) ---
        vid_strh_body = struct.pack(
            "<4s4sI2H8I4h",
            b"vids", b"MJPG",
            0,                           # dwFlags
            0, 0,                        # wPriority, wLanguage
            0,                           # dwInitialFrames
            scale,                       # dwScale
            rate,                        # dwRate  (rate/scale = fps)
            0,                           # dwStart
            self.frames,                 # dwLength (frames)
            max(self._max_jpeg, 1),      # dwSuggestedBufferSize
            0,                           # dwQuality
            0,                           # dwSampleSize (0 = variable)
            0, 0, self.width, self.height)   # rcFrame
        vid_strh = b"strh" + struct.pack("<I", len(vid_strh_body)) + vid_strh_body
 
        vid_strf_body = struct.pack(
            "<IiiHH4sIiiII",
            40,                          # biSize
            self.width,                  # biWidth
            self.height,                 # biHeight
            1,                           # biPlanes
            24,                          # biBitCount
            b"MJPG",                     # biCompression
            self.width * self.height * 3,  # biSizeImage
            0, 0,                        # biX/YPelsPerMeter
            0, 0)                        # biClrUsed, biClrImportant
        vid_strf = b"strf" + struct.pack("<I", len(vid_strf_body)) + vid_strf_body
 
        vid_strl_body = b"strl" + vid_strh + vid_strf
        vid_strl = b"LIST" + struct.pack("<I", len(vid_strl_body)) + vid_strl_body
 
        hdrl = avih + vid_strl
 
        # --- audio stream list (auds / PCM) ---
        if self.record_audio:
            aud_blocks = self._audio_bytes // self._block_align
            aud_strh_body = struct.pack(
                "<4s4sI2H8I4h",
                b"auds", b"\x00\x00\x00\x00",
                0,                       # dwFlags
                0, 0,                    # wPriority, wLanguage
                0,                       # dwInitialFrames
                self._block_align,       # dwScale
                self._avg_bps,           # dwRate (rate/scale = samples/sec)
                0,                       # dwStart
                aud_blocks,              # dwLength (in blocks/samples)
                max(self._max_audio, 1),  # dwSuggestedBufferSize
                0,                       # dwQuality
                self._block_align,       # dwSampleSize
                0, 0, 0, 0)              # rcFrame (unused for audio)
            aud_strh = b"strh" + struct.pack("<I", len(aud_strh_body)) + aud_strh_body
 
            aud_strf_body = struct.pack(
                "<HHIIHH",
                1,                       # wFormatTag = WAVE_FORMAT_PCM
                self.audio_channels,     # nChannels
                self.audio_frequency,    # nSamplesPerSec
                self._avg_bps,           # nAvgBytesPerSec
                self._block_align,       # nBlockAlign
                self._bits)              # wBitsPerSample
            aud_strf = b"strf" + struct.pack("<I", len(aud_strf_body)) + aud_strf_body
 
            aud_strl_body = b"strl" + aud_strh + aud_strf
            aud_strl = b"LIST" + struct.pack("<I", len(aud_strl_body)) + aud_strl_body
            hdrl += aud_strl
 
        return hdrl

Which you then use like:

import csi, time
from avi_recorder import AviWriter

cam = csi.CSI()
cam.reset()
cam.pixformat(csi.RGB565)     # writer JPEG-compresses each frame
cam.framesize(csi.QVGA)       # 320x240

avi = AviWriter("recording.avi", 320, 240, quality=90,
                audio_frequency=16000, audio_channels=1)

record_secs = 10
target_fps = 30
frame_ms = 1000 // target_fps

start = time.ticks_ms()
while time.ticks_diff(time.ticks_ms(), start) < record_secs * 1000:
    t = time.ticks_ms()
    avi.add_frame(cam.snapshot())
    dt = time.ticks_diff(time.ticks_ms(), t)
    if dt < frame_ms:
        time.sleep_ms(frame_ms - dt)

avi.close()                   # finalizes header + index, stops audio

A few design points worth flagging:

FPS is measured, not assumed. add_frame() timestamps with time.ticks_us(), and close() seeks back and rewrites the fixed-size preamble with the real frame rate, total frame count, and audio length — so you don’t have to declare a frame rate up front and it stays honest even if capture rate drifts.

Audio comes in through audio.start_streaming(self._audio_cb); the callback copies each buffer (bytes(pcmbuf), since the driver reuses it) into a queue, and the queue is flushed as 01wb chunks immediately before each 00dc video chunk. That keeps the interleave roughly time-ordered, and any tail buffers are drained after stop_streaming() in close(). Video length is derived from wall-clock time while audio length comes from the actual sample count, so the two durations line up.

add_frame() takes either an image.Image (RGB565/GRAYSCALE — it calls img.compress(quality=...).bytearray() for you) or raw JPEG bytes.

The index (idx1) is written with offsets relative to the movi FOURCC and AVIIF_KEYFRAME set on every JPEG frame, which is what makes it seekable in VLC/QuickTime rather than just linearly playable.

One thing to sanity-check on real hardware: I stubbed the audio module for the host test, so confirm your firmware build’s exact audio API (audio.init(channels=, frequency=, gain_db=) / start_streaming / stop_streaming) matches — the docs snippet I pulled shows exactly that signature, but if stop_streaming is named differently in v5.0.0 the close() call is the only spot to adjust.

Sources: OpenMV N6 board interface

recording.zip (20.9 MB)

Thanks for your reply, this is very helpful. Video recording runs smoothly. I am currently attempting to transfer files directly to my computer using the Wi-Fi module. I have encountered an issue though: the Wi-Fi connection works normally using raw TCP, but the connection cannot be established when I switch to HTTP. Could you help me troubleshoot this?

import csi, time, os
import network, socket
from avi_recorder import AviWriter
from machine import LED

# ============================================================
# CONFIGURATION – fill in your actual values
# ============================================================
OUTPUT_FILE   = "/sdcard/hd_record.avi"   # Output file path on SD card
RECORD_SECS   = 15                        # Recording duration (seconds)
QUALITY       = 70                        # JPEG quality 1-100 (lower = smaller file, higher fps)

WIFI_SSID     = "YOUR_WIFI_SSID"          # 2.4 GHz Wi‑Fi SSID
WIFI_PASS     = "YOUR_WIFI_PASSWORD"      # Wi‑Fi password
PC_IP         = "YOUR_PC_IP"              # IPv4 address of the receiving PC
PC_PORT       = 12345                     # TCP port the PC listener is bound to
# ============================================================

# ============================================================
# Onboard RGB LED status definitions
# ============================================================
led_red   = LED("LED_RED")
led_green = LED("LED_GREEN")
led_blue  = LED("LED_BLUE")

def led(state):
    """Switch LEDs according to system state for quick visual feedback."""
    led_red.off(); led_green.off(); led_blue.off()
    if state == "ready":  led_green.on()                     # Green – ready
    elif state == "rec":  led_red.on()                       # Red – recording
    elif state == "wifi": led_green.on(); led_blue.on()      # Cyan – Wi‑Fi / sending
    elif state == "done": led_blue.on()                      # Blue – transfer finished
    elif state == "err":  led_red.on(); led_green.on()       # Yellow (red+green) – error

# ============================================================
# 1. Camera initialisation (no audio yet)
# ============================================================
led("ready")
cam = csi.CSI()
cam.reset()
cam.pixformat(csi.RGB565)          # 16-bit colour
cam.framesize(csi.HD)              # 1280×720
cam.snapshot(time=2000)            # 2-second warm‑up to stabilise auto exposure / white balance

# ============================================================
# 2. Countdown (audio not started yet to avoid capturing silence)
# ============================================================
print("Starting in 2 seconds...")
time.sleep(2)                      # Give the operator a moment to prepare
print("===== Recording started =====")

# ============================================================
# 3. Create AVI writer (audio stream starts now, in sync with video)
# ============================================================
try:
    avi = AviWriter(OUTPUT_FILE, width=1280, height=720,
                    quality=QUALITY, audio_frequency=16000, audio_channels=1)
except Exception as e:
    print("AVI creation failed:", e)
    led("err")
    while True: time.sleep(1)      # Halt with red+green LED, wait for reset

led("rec")                         # Recording indicator
frame_cnt = 0
start_ms = time.ticks_ms()

# Capture continuously without artificial delays to maximise throughput
while time.ticks_diff(time.ticks_ms(), start_ms) < RECORD_SECS * 1000:
    avi.add_frame(cam.snapshot())  # Grab a frame; audio is interleaved automatically
    frame_cnt += 1

# Calculate actual frame rate
actual_ms = time.ticks_diff(time.ticks_ms(), start_ms)
actual_fps = frame_cnt / (actual_ms / 1000) if actual_ms > 0 else 15

# ============================================================
# 4. Finalise recording – write true frame rate for perfect A/V sync
# ============================================================
avi.set_fps(actual_fps)            # Must be called, otherwise header defaults to 30 fps
avi.close()                        # Stop audio, write index, patch headers

print(f"Actual FPS: {actual_fps:.1f}, total frames: {frame_cnt}")
print(f"Recording duration: {actual_ms/1000:.1f}s (playback will match)")

# ============================================================
# 5. File verification – make sure the AVI was written to the SD card
# ============================================================
try:
    if "hd_record.avi" not in os.listdir("/sdcard"):
        print("Error: file not created")
        led("err")
        while True: time.sleep(1)
    file_size = os.stat(OUTPUT_FILE)[6]   # Get file size in bytes
    print(f"File size: {file_size} bytes")
except Exception as e:
    print("File check failed:", e)
    led("err")
    while True: time.sleep(1)

# ============================================================
# 6. Reliable send helper – guarantees all bytes are transmitted
# ============================================================
def safe_send(sock, data):
    total = len(data)
    sent = 0
    while sent < total:
        n = sock.send(data[sent:])
        if n == 0:
            raise OSError("Connection closed")
        sent += n
    return sent

# ============================================================
# 7. Connect to Wi‑Fi (2.4 GHz)
# ============================================================
led("wifi")
print("Connecting to Wi‑Fi...")
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(WIFI_SSID, WIFI_PASS)

wifi_cnt = 0
while not wlan.isconnected() and wifi_cnt < 20:   # Timeout after ~10 seconds
    time.sleep_ms(500)
    print(".", end="")
    wifi_cnt += 1

if not wlan.isconnected():
    print("\nWi‑Fi connection failed. File remains on SD card – transfer it manually.")
    led("err")
    while True: time.sleep(1)

print(f"\nWi‑Fi connected. Device IP: {wlan.ifconfig()[0]}")
print(f"Connecting to PC {PC_IP}:{PC_PORT} ...")

# ============================================================
# 8. Transfer the AVI file to the PC via raw TCP
#     The PC must be running the receiver script on the same port
# ============================================================
try:
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.settimeout(30)                           # Longer timeout for large files
    client.connect((PC_IP, PC_PORT))
    print("Connected to PC. Starting file transfer.")

    # Send file metadata: filename length (1 byte) + filename + file size (4 bytes, little-endian)
    file_name = OUTPUT_FILE.split("/")[-1]
    name_bytes = file_name.encode("utf-8")
    safe_send(client, len(name_bytes).to_bytes(1, "little"))
    safe_send(client, name_bytes)
    safe_send(client, file_size.to_bytes(4, "little"))

    # Send file contents in 4 KB chunks
    sent_total = 0
    chunk_size = 4096
    with open(OUTPUT_FILE, "rb") as f:
        while True:
            chunk = f.read(chunk_size)
            if not chunk:
                break
            safe_send(client, chunk)
            sent_total += len(chunk)

    # Final integrity check
    if sent_total != file_size:
        raise Exception(f"Byte mismatch: sent {sent_total}, expected {file_size}")

    client.close()
    led("done")
    print(f"✅ Transfer complete. {sent_total} bytes sent, file intact.")

except Exception as e:
    print(f"Transfer failed: {e}")
    print("File remains on SD card – transfer it manually.")
    led("err")

# Idle forever
while True:
    time.sleep(1)
HTTP
import csi, time, os, gc
import network, socket
from avi_recorder import AviWriter
from machine import LED

# ============================================================
# CONFIGURATION – fill in your actual values
# ============================================================
OUTPUT_FILE   = "/sdcard/hd_record.avi"   # File path on SD card
RECORD_SECS   = 6                         # Recording duration (seconds)
QUALITY       = 70                        # JPEG quality 1-100 (lower = smaller file, higher fps)

WIFI_SSID     = "YOUR_WIFI_SSID"          # 2.4 GHz Wi‑Fi name
WIFI_PASS     = "YOUR_WIFI_PASSWORD"      # Wi‑Fi password
SERVER_URL    = "http://YOUR_PC_IP:12345/upload"  # HTTP upload endpoint (http:// only)
API_KEY       = ""                        # Bearer token if required, otherwise leave empty
# ============================================================

# ============================================================
# Onboard RGB LED status definitions
# ============================================================
led_red   = LED("LED_RED")
led_green = LED("LED_GREEN")
led_blue  = LED("LED_BLUE")

def led(state):
    """Set LED colours for quick visual feedback of system state."""
    led_red.off(); led_green.off(); led_blue.off()
    if state == "ready":   led_green.on()                     # Green – ready
    elif state == "rec":   led_red.on()                       # Red – recording
    elif state == "wifi":  led_green.on(); led_blue.on()      # Cyan – Wi‑Fi / uploading
    elif state == "done":  led_blue.on()                      # Blue – success
    elif state == "err":   led_red.on(); led_green.on()       # Yellow (red+green) – error

# ============================================================
# 1. Camera setup (no audio yet)
# ============================================================
led("ready")
cam = csi.CSI()
cam.reset()
cam.pixformat(csi.RGB565)          # 16-bit colour
cam.framesize(csi.HD)              # 1280×720
cam.snapshot(time=2000)            # 2-second warm‑up to stabilise exposure

# ============================================================
# 2. Countdown before recording
# ============================================================
print("Starting in 2 seconds...")
time.sleep(2)
print("===== Recording started =====")

# ============================================================
# 3. Create AVI writer (audio starts now) and record
# ============================================================
try:
    avi = AviWriter(OUTPUT_FILE, width=1280, height=720,
                    quality=QUALITY, audio_frequency=16000, audio_channels=1)
except Exception as e:
    print("AVI creation failed:", e)
    led("err")
    while True: time.sleep(1)

led("rec")
frame_cnt = 0
start_ms = time.ticks_ms()

while time.ticks_diff(time.ticks_ms(), start_ms) < RECORD_SECS * 1000:
    avi.add_frame(cam.snapshot())
    frame_cnt += 1

actual_ms = time.ticks_diff(time.ticks_ms(), start_ms)
actual_fps = frame_cnt / (actual_ms / 1000) if actual_ms > 0 else 15
avi.set_fps(actual_fps)   # write true frame rate to header → perfect A/V sync
avi.close()               # stop audio, finalise index and file headers

print(f"Actual FPS: {actual_fps:.1f}, total frames: {frame_cnt}")
print(f"Recording duration: {actual_ms/1000:.1f}s")

# ============================================================
# 4. Verify the file exists on SD card
# ============================================================
try:
    if "hd_record.avi" not in os.listdir("/sdcard"):
        print("Error: file not created")
        led("err")
        while True: time.sleep(1)
    file_size = os.stat(OUTPUT_FILE)[6]
    print(f"File size: {file_size} bytes")
except Exception as e:
    print("File check failed:", e)
    led("err")
    while True: time.sleep(1)

# ============================================================
# 5. Wi‑Fi connection (with memory cleanup and retry)
# ============================================================
led("wifi")
print("Connecting to Wi‑Fi...")

gc.collect()                # free memory before network stack initialisation
time.sleep(0.5)

wlan = network.WLAN(network.STA_IF)
wlan.active(False)          # reset interface
time.sleep_ms(200)
wlan.active(True)
wlan.connect(WIFI_SSID, WIFI_PASS)

# First attempt – wait up to 15 seconds
wifi_cnt = 0
while not wlan.isconnected() and wifi_cnt < 30:
    time.sleep_ms(500)
    print(".", end="")
    wifi_cnt += 1

# Retry once if first attempt fails
if not wlan.isconnected():
    print("\nFirst attempt failed, retrying after 2s...")
    time.sleep(2)
    gc.collect()
    wlan.active(False)
    time.sleep_ms(200)
    wlan.active(True)
    wlan.connect(WIFI_SSID, WIFI_PASS)
    retry_cnt = 0
    while not wlan.isconnected() and retry_cnt < 30:
        time.sleep_ms(500)
        print(".", end="")
        retry_cnt += 1

    if not wlan.isconnected():
        print("\nWi‑Fi connection failed. File remains on SD card.")
        led("err")
        while True: time.sleep(1)

print(f"\nWi‑Fi connected. Device IP: {wlan.ifconfig()[0]}")
gc.collect()  # another cleanup before the upload

# ============================================================
# 6. Streaming HTTP upload (avoids loading the whole file into RAM)
# ============================================================
print(f"Uploading to {SERVER_URL} ...")

# ----- Parse the server URL -----
url = SERVER_URL.replace("http://", "")
if "/" in url:
    host_port, path = url.split("/", 1)
    path = "/" + path
else:
    host_port, path = url, "/"
if ":" in host_port:
    host, port_str = host_port.split(":", 1)
    port = int(port_str)
else:
    host, port = host_port, 80

# ----- Build multipart/form-data envelope -----
boundary = "----N6Up"
head = (
    f"--{boundary}\r\n"
    'Content-Disposition: form-data; name="file"; filename="hd_record.avi"\r\n'
    "Content-Type: application/octet-stream\r\n\r\n"
).encode()
tail = f"\r\n--{boundary}--\r\n".encode()
body_len = len(head) + file_size + len(tail)

header = (
    f"POST {path} HTTP/1.1\r\n"
    f"Host: {host}\r\n"
    f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
    f"Content-Length: {body_len}\r\n"
)
if API_KEY:
    header += f"Authorization: Bearer {API_KEY}\r\n"
header += "Connection: close\r\n\r\n"

try:
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(30)
    sock.connect((host, port))

    # Send headers and multipart head
    sock.send(header.encode())
    sock.send(head)

    # Send file in 4 KB chunks (never loads the entire file into memory)
    with open(OUTPUT_FILE, "rb") as f:
        while True:
            chunk = f.read(4096)
            if not chunk:
                break
            sock.send(chunk)

    # Finalise multipart body
    sock.send(tail)

    # Read server response
    response = b""
    try:
        while True:
            chunk = sock.recv(1024)
            if not chunk:
                break
            response += chunk
    except:
        pass
    sock.close()

    # Check if the server signalled success (optional)
    if b"200" in response or b"ok" in response:
        led("done")
        print("✅ Upload successful")
    else:
        print("Server response:", response[:200])
        led("err")
except Exception as e:
    print(f"Upload failed: {e}")
    print("File remains on SD card – transfer it manually.")
    led("err")

# Keep the board idle after finishing
while True:
time.sleep(1)

Hi Kui,

HTTP runs on top of TCP — the connect() call in both of your scripts is doing exactly the same thing. So if the raw-TCP script connects and the HTTP script doesn’t, the difference isn’t the protocol, it’s what’s listening on the PC. Check these first:

  1. Is your HTTP server actually running when the camera connects? Your raw-TCP test used a custom receiver script on port 12345 — the HTTP test needs a different server program listening on that same port.
  2. Is it bound to 0.0.0.0 and not 127.0.0.1/localhost? A server bound to localhost accepts connections only from the PC itself; the camera will get “connection refused.”
  3. Windows Firewall prompts per-program. Your TCP receiver may have been allowed already while the new HTTP server got silently blocked. Allow Python through the firewall for private networks (or temporarily disable it to test).
  4. Note: python -m http.server won’t work — it only serves GET requests, it can’t accept uploads.

Here’s a minimal PC-side upload server that matches your camera script (save as upload_server.py, run with python upload_server.py):slight_smile:

from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers["Content-Length"])
        data = self.rfile.read(length)
        # Strip the multipart envelope to recover the raw AVI bytes
        boundary = self.headers["Content-Type"].split("boundary=")[1].encode()
        start = data.find(b"\r\n\r\n") + 4
        end = data.rfind(b"\r\n--" + boundary + b"--")
        with open("hd_record.avi", "wb") as f:
            f.write(data[start:end])
        print(f"Received {end - start} bytes")
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok")

print("Listening on 0.0.0.0:12345 ...")
HTTPServer(("0.0.0.0", 12345), Handler).serve_forever()

A few fixes for your camera script too:

1. Use safe_send() for the HTTP upload as well. Your raw-TCP script correctly loops until all bytes are sent, but the HTTP script uses bare sock.send(), which is allowed to send fewer bytes than you gave it. On a large file you’ll eventually get a short send and a corrupted upload. Replace every sock.send(...) in section 6 with the same safe_send(sock, ...) helper from your TCP script.

2. Resolve the address with getaddrinfo(). It’s more portable and lets you use hostnames, not just IPs:

addr = socket.getaddrinfo(host, port)[0][-1]
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(30)
sock.connect(addr)

3. Fix the indentation at the very end of the script — the last two lines as posted are:

while True:
time.sleep(1)

time.sleep(1) needs to be indented, otherwise the script dies with an IndentationError before it even starts (if this is just a copy-paste artifact, ignore this one).

With the server above running and the firewall open, your script should work — I’d suggest testing connectivity first with a short recording (RECORD_SECS = 2) so you’re not waiting on a big file each attempt.

kwagyeman,Thank you very much for your help!!! I have completed the first step as required`.# Fixed PC HTTP receiver service, listening on 0.0.0.0:12346, compatible with OpenMV upload format

Save this file as upload_server.py, run via command: python upload_server.py

from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
def do_POST(self):
try:
# Read total request payload length
content_len = int(self.headers["Content-Length"])
full_req = self.rfile.read(content_len)

        # Extract multipart boundary from request header
        boundary = self.headers["Content-Type"].split("boundary=")[1].encode()

        # Strip multipart wrapper headers and trailers, extract raw AVI binary data
        data_start = full_req.find(b"\r\n\r\n") + 4
        data_end = full_req.rfind(b"\r\n--" + boundary + b"--")
        video_raw = full_req[data_start:data_end]

        # Write binary video stream to local file
        with open("hd_record.avi", "wb") as f_out:
            f_out.write(video_raw)

        print(f"✅ Video received successfully, payload bytes: {len(video_raw)}")
        # Respond 200 OK to OpenMV device
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok")

    except Exception as err:
        print("Error parsing incoming request:", err)
        self.send_response(400)
        self.end_headers()

if name == "main":
# Critical fix: address tuple format (bind_address, port)
listen_addr = ("0.0.0.0", 12346)
http_server = HTTPServer(listen_addr, Handler)

print("📡 HTTP upload server started, listening on 0.0.0.0:12346")
print("⚠️ Allow private network access in firewall; disable old TCP receiver script")
http_server.serve_forever()`

the recorded code in the IDE # OpenMV N6 A/V recording → HTTP chunked streaming upload (Port updated to 12346)

# Workflow: Record AVI -> Save to SD Card -> WiFi STA -> HTTP POST multipart upload
# Dependency: avi_recorder.py in the same directory
import csi, time, os, gc
import network, socket
from avi_recorder import AviWriter
from machine import LED

# ============================================================
# Configuration Section - Modify according to your environment
# ============================================================
OUTPUT_FILE   = "/sdcard/hd_record.avi"
RECORD_SECS   = 180       # Use shorter duration for testing; extend after debugging
QUALITY       = 70

WIFI_SSID     = "YOUR_WIFI_NAME"
WIFI_PASS     = "YOUR_WIFI_PASSWORD"

# Upload target: PC IP + Port, server root path /
PC_HOST       = "YOUR_PC_IP"
PC_PORT       = 12346   # Port matched with HTTP server
API_KEY       = ""
BOUNDARY      = b"----OpenMVN6Upload" # Must match parser boundary on server side
CHUNK_SIZE    = 4096                  # Read/write chunk buffer size
# ============================================================

# ============================================================
# LED Status Indicator Control
# ============================================================
led_red   = LED("LED_RED")
led_green = LED("LED_GREEN")
led_blue  = LED("LED_BLUE")

def led(state):
    led_red.off(); led_green.off(); led_blue.off()
    if state == "ready":  led_green.on()
    elif state == "rec":  led_red.on()
    elif state == "wifi": led_green.on(); led_blue.on()
    elif state == "done": led_blue.on()
    elif state == "err":  led_red.on()

# ============================================================
# Reliable transmission wrapper; all socket writes use this function
# ============================================================
def safe_send(sock, data):
    total_len = len(data)
    send_ptr = 0
    while send_ptr < total_len:
        sent = sock.send(data[send_ptr:])
        if sent <= 0:
            raise OSError("TCP connection lost, transmission failed")
        send_ptr += sent
    return send_ptr

# ============================================================
# 1. Camera initialization and warm-up
# ============================================================
led("ready")
cam = csi.CSI()
cam.reset()
cam.pixformat(csi.RGB565)
cam.framesize(csi.HD)
cam.snapshot(time=2000)

# ============================================================
# 2. Recording countdown
# ============================================================
print("Recording starts in 2 seconds...")
time.sleep(2)
print("===== START RECORDING =====")

# ============================================================
# 3. AVI recording, print instantaneous frame rate per frame
# ============================================================
try:
    avi = AviWriter(OUTPUT_FILE, width=1280, height=720,
                    quality=QUALITY, audio_frequency=16000, audio_channels=1)
except Exception as e:
    print("Failed to create AVI file:", e)
    led("err")
    while True:
        time.sleep(1)

led("rec")
frame_cnt = 0
start_ms = time.ticks_ms()
last_frame_tick = time.ticks_ms()

# Recording loop, output instantaneous FPS for each captured frame
while time.ticks_diff(time.ticks_ms(), start_ms) < RECORD_SECS * 1000:
    avi.add_frame(cam.snapshot())
    frame_cnt += 1
    # Calculate instantaneous frame rate
    curr_tick = time.ticks_ms()
    frame_gap = time.ticks_diff(curr_tick, last_frame_tick)
    instant_fps = 1000 / frame_gap if frame_gap > 0 else 0
    print(f"Instant FPS:{instant_fps:.1f} | Frames captured:{frame_cnt}")
    last_frame_tick = curr_tick

# Finalize recording, write actual FPS into file header
actual_ms = time.ticks_diff(time.ticks_ms(), start_ms)
actual_fps = frame_cnt / (actual_ms / 1000) if actual_ms > 0 else 15
avi.set_fps(actual_fps)
avi.close()
gc.collect()

print("\n===== RECORD SUMMARY =====")
print(f"Average FPS:{actual_fps:.1f} Total frames:{frame_cnt}")
print(f"Recording duration:{actual_ms/1000:.1f}s")

# ============================================================
# 4. SD card file integrity verification
# ============================================================
try:
    file_list = os.listdir("/sdcard")
    if "hd_record.avi" not in file_list:
        print("Error: Recorded file not found on SD card")
        led("err")
        while True:
            time.sleep(1)
    file_size = os.stat(OUTPUT_FILE)[6]
    print(f"AVI file total bytes:{file_size}")
except Exception as e:
    print("File validation error:", e)
    led("err")
    while True:
        time.sleep(1)

# ============================================================
# 5. WiFi STA connection (hardware reset to reduce connection failures)
# ============================================================
led("wifi")
print("Connecting to WiFi...")
wlan = network.WLAN(network.STA_IF)
# Reset WiFi to release resources, fixes repeated connection failures
wlan.active(False)
time.sleep_ms(800)
wlan.active(True)
wlan.connect(WIFI_SSID, WIFI_PASS)

wifi_cnt = 0
while not wlan.isconnected() and wifi_cnt < 30: # Max wait ~15 seconds
    time.sleep_ms(500)
    print(".", end="")
    wifi_cnt += 1

if not wlan.isconnected():
    print("\nWiFi connection failed, video remains stored on SD card")
    led("err")
    while True:
        time.sleep(1)

print(f"\nWiFi connected, device IP:{wlan.ifconfig()[0]}")

# ============================================================
# 6. HTTP chunked streaming upload (fixed multi-line syntax issue)
# ============================================================
def upload_avi_stream(host, port, file_path, timeout=60):
    # Resolve address via getaddrinfo
    addr_info = socket.getaddrinfo(host, port)[0][-1]
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(timeout)
    sock.connect(addr_info)

    file_name = file_path.split("/")[-1]

    # Build multipart header, avoid MicroPython multi-line bracket syntax error
    preamble_text = "--" + BOUNDARY.decode() + "\r\n"
    preamble_text += 'Content-Disposition: form-data; name="file"; filename="%s"\r\n' % file_name
    preamble_text += "Content-Type: video/x-msvideo\r\n\r\n"
    preamble = preamble_text.encode("utf-8")

    epilogue_text = "\r\n--" + BOUNDARY.decode() + "--\r\n"
    epilogue = epilogue_text.encode("utf-8")

    # Calculate total body length
    f_stat = os.stat(file_path)
    total_body_len = len(preamble) + f_stat[6] + len(epilogue)

    # Construct HTTP request header line by line to avoid MicroPython syntax bugs
    http_header = b"POST / HTTP/1.1\r\n"
    http_header += b"Host: %s\r\n" % host.encode()
    http_header += b"Content-Type: multipart/form-data; boundary=%s\r\n" % BOUNDARY
    http_header += b"Content-Length: %d\r\n" % total_body_len
    http_header += b"Connection: close\r\n\r\n"

    safe_send(sock, http_header)
    safe_send(sock, preamble)

    # Read and transmit SD file in chunks
    with open(file_path, "rb") as f:
        while True:
            chunk = f.read(CHUNK_SIZE)
            if not chunk:
                break
            safe_send(sock, chunk)
            gc.collect()

    safe_send(sock, epilogue)

    resp_buf = b""
    while True:
        recv_data = sock.recv(1024)
        if not recv_data:
            break
        resp_buf += recv_data
    sock.close()
    gc.collect()
    return resp_buf

# ============================================================
# 7. Execute upload procedure
# ============================================================
print(f"Start HTTP push to {PC_HOST}:{PC_PORT}")
try:
    server_resp = upload_avi_stream(PC_HOST, PC_PORT, OUTPUT_FILE)
    print("Raw server response:", server_resp[:300].decode("utf-8", "ignore"))
    if b"200" in server_resp:
        led("done")
        print("✅ HTTP file upload completed successfully!")
    else:
        raise Exception("Server did not return 200 OK status")
except Exception as e:
    print(f"HTTP upload failed: {e}")
    print("AVI file saved locally on SD card, you can copy it manually")
    led("err")

# Idle infinite loop after workflow completes
while True:
    time.sleep(1)

Finally, I also have a question: the transfer method is extremely slow – it takes over 20 minutes to transfer a 3‑minute video. Is there something wrong with the way I wrote it?

The code I shared this time isn’t polished. I’ll send you a standardized version tomorrow for your review.

Hi, you can use this script to test transfer performance: micropython-lib/python-ecosys/iperf3/iperf3.py at master · micropython/micropython-lib · GitHub

You can run it on linux and micropython. With it you can test max transfer speeds. Yes, how you write code matters. Note that TCP performance isn’t extremely high out of the box as it requires massive LWIP buffers.

Hi ! I’ve been experimenting with a WiFi module to implement data transmission over HTTP. Below is the code I wrote for session-based data transfer.

My receiving end can achieve a data reception speed of 1 MB/s, yet the transmission throughput from my OpenMV N6 barely hits 50 KB/s. I’m reaching out to ask: are there any optimizations or adjustments I can make to the code attached below?

"""OpenMV N6 HTTP client for Meeting ASR 0.5 device sessions.

This module intentionally uses plain sockets and HTTP. Deploy it only on the

trusted LAN configured on the Meeting ASR receiver.

"""

try:

    import ujson as json

except ImportError:  # CPython tests

    import json

import socket

import struct

import time

AV_AUDIO = 1

AV_JPEG = 2

AV_END = 255

AV_PROTOCOL = "mav1"

UPLOAD_CHUNK_BYTES = 1024 * 1024

def _safe_send(sock, data):

    """Reliably send a bytes-like object without making a second copy."""

    view = data if isinstance(data, memoryview) else memoryview(data)

    sock.sendall(view)

def ticks_ms():

    function = getattr(time, "ticks_ms", None)

    if function:

        return function()

    return int(time.monotonic() * 1000)

def ticks_diff(current, previous):

    function = getattr(time, "ticks_diff", None)

    if function:

        return function(current, previous)

    return current - previous

def sleep_ms(milliseconds):

    function = getattr(time, "sleep_ms", None)

    if function:

        function(milliseconds)

    else:

        time.sleep(milliseconds / 1000.0)

def wav_header(pcm_bytes, sample_rate=16000, channels=1, bits_per_sample=16):

    """Return a canonical 44-byte little-endian PCM WAV header."""

    byte_rate = sample_rate * channels * bits_per_sample // 8

    block_align = channels * bits_per_sample // 8

    return struct.pack(

        "<4sI4s4sIHHIIHH4sI",

        b"RIFF",

        36 + pcm_bytes,

        b"WAVE",

        b"fmt ",

        16,

        1,

        channels,

        sample_rate,

        byte_rate,

        block_align,

        bits_per_sample,

        b"data",

        pcm_bytes,

    )

def av_record(record_type, sequence, timestamp_ms, payload=b""):

    """Encode one mav1 record using the fixed !BBHIII header."""

    if record_type not in (AV_AUDIO, AV_JPEG, AV_END):

        raise ValueError("invalid A/V record type")

    if sequence < 0 or sequence > 0xFFFFFFFF:

        raise ValueError("invalid A/V sequence")

    if timestamp_ms < 0 or timestamp_ms > 0xFFFFFFFF:

        raise ValueError("invalid A/V timestamp")

    if len(payload) > 512 * 1024:

        raise ValueError("A/V payload is too large")

    return struct.pack(

        ">BBHIII",

        record_type,

        0,

        0,

        sequence,

        timestamp_ms,

        len(payload),

    ) + payload

class PcmRing:

    """Fixed-storage PCM queue safe to fill from the audio callback."""

    def __init__(self, capacity=16, slot_bytes=2048):

        self.capacity = capacity

        self.slot_bytes = slot_bytes

        self.slots = [bytearray(slot_bytes) for _ in range(capacity)]

        self.lengths = [0] * capacity

        self.timestamps = [0] * capacity

        self.head = 0

        self.tail = 0

        self.count = 0

        self.overflowed = False

    def push(self, payload, timestamp_ms=0):

        length = len(payload)

        if self.count >= self.capacity or length > self.slot_bytes:

            self.overflowed = True

            return False

        self.slots[self.tail][:length] = payload

        self.lengths[self.tail] = length

        self.timestamps[self.tail] = timestamp_ms

        self.tail = (self.tail + 1) % self.capacity

        self.count += 1

        return True

    def pop(self):

        item = self.pop_with_timestamp()

        return None if item is None else item[1]

    def pop_with_timestamp(self):

        if self.count == 0:

            return None

        index = self.head

        length = self.lengths[index]

        timestamp_ms = self.timestamps[index]

        self.head = (self.head + 1) % self.capacity

        self.count -= 1

        return timestamp_ms, memoryview(self.slots[index])[:length]

def _parse_http_url(value):

    if not value.startswith("http://"):

        raise ValueError("Only trusted-LAN http:// server URLs are supported")

    authority = value[7:].rstrip("/")

    if "/" in authority or "@" in authority:

        raise ValueError("SERVER_URL must be an HTTP origin without credentials")

    if authority.startswith("["):

        closing = authority.find("]")

        if closing < 0:

            raise ValueError("invalid IPv6 SERVER_URL")

        host = authority[1:closing]

        port = int(authority[closing + 2 :]) if authority[closing + 1 :] else 80

    elif ":" in authority:

        host, raw_port = authority.rsplit(":", 1)

        port = int(raw_port)

    else:

        host = authority

        port = 80

    if not host or port < 1 or port > 65535:

        raise ValueError("invalid SERVER_URL")

    return host, port

class HttpError(Exception):

    def __init__(self, status, payload):

        self.status = status

        self.payload = payload

        Exception.__init__(self, "HTTP %s: %s" % (status, payload))

class _SocketReader:

    def __init__(self, sock, initial=b""):

        self.sock = sock

        self.buffer = initial

    def read_exact(self, length):

        chunks = []

        if self.buffer:

            take = min(length, len(self.buffer))

            chunks.append(self.buffer[:take])

            self.buffer = self.buffer[take:]

            length -= take

        while length:

            chunk = self.sock.recv(min(length, 4096))

            if not chunk:

                raise OSError("HTTP response ended early")

            chunks.append(chunk)

            length -= len(chunk)

        return b"".join(chunks)

def _read_response(sock):

    head = b""

    while b"\r\n\r\n" not in head:

        chunk = sock.recv(1024)

        if not chunk:

            raise OSError("HTTP response header ended early")

        head += chunk

        if len(head) > 16 * 1024:

            raise OSError("HTTP response header is too large")

    raw_head, initial = head.split(b"\r\n\r\n", 1)

    lines = raw_head.decode("utf-8").split("\r\n")

    parts = lines[0].split(" ", 2)

    if len(parts) < 2:

        raise OSError("invalid HTTP status line")

    status = int(parts[1])

    headers = {}

    for line in lines[1:]:

        if ":" not in line:

            continue

        name, value = line.split(":", 1)

        headers[name.strip().lower()] = value.strip()

    length = int(headers.get("content-length", "0"))

    body = _SocketReader(sock, initial).read_exact(length) if length else b""

    payload = None

    if body:

        content_type = headers.get("content-type", "")

        if "json" in content_type:

            payload = json.loads(body.decode("utf-8"))

        else:

            payload = body

    return status, headers, payload

class LEDStatus:

    """N6 user RGB LED state helper."""

    def __init__(self):

        try:

            from machine import LED

            self.red = LED("LED_RED")

            self.green = LED("LED_GREEN")

            self.blue = LED("LED_BLUE")

        except (ImportError, ValueError):

            self.red = self.green = self.blue = None

    def _set(self, red=False, green=False, blue=False):

        for item, enabled in (

            (self.red, red),

            (self.green, green),

            (self.blue, blue),

        ):

            if item is not None:

                item.on() if enabled else item.off()

    def connecting(self):

        self._set(blue=True)

    def active(self):

        self._set(green=True)

    def uploading(self, alternate=False):

        self._set(green=alternate, blue=not alternate)

    def error(self):

        self._set(red=True)

    def off(self):

        self._set()

def connect_wifi(ssid, password, timeout_seconds=30):

    import network

    wlan = network.WLAN(network.STA_IF)

    wlan.active(True)

    if not wlan.isconnected():

        wlan.connect(ssid, password)

    started = ticks_ms()

    while not wlan.isconnected():

        if ticks_diff(ticks_ms(), started) > timeout_seconds * 1000:

            raise OSError("Wi-Fi connection timed out")

        sleep_ms(250)

    return wlan

class MeetingAsrClient:

    def __init__(self, server_url, device_id, device_token, timeout_seconds=30):

        self.server_url = server_url.rstrip("/")

        self.host, self.port = _parse_http_url(self.server_url)

        self.device_id = device_id

        self.device_token = device_token

        self.timeout_seconds = timeout_seconds

    def _connect(self):

        address = socket.getaddrinfo(self.host, self.port, 0, socket.SOCK_STREAM)[0][-1]

        sock = socket.socket()

        sock.settimeout(self.timeout_seconds)

        sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)

        sock.connect(address)

        return sock

    def _request(self, method, path, token, body=None, headers=None):

        headers = dict(headers or {})

        raw_body = b""

        if body is not None:

            if isinstance(body, (bytes, bytearray, memoryview)):

                raw_body = body

            else:

                raw_body = json.dumps(body).encode("utf-8")

                headers["Content-Type"] = "application/json"

        headers["Host"] = self.host

        headers["Authorization"] = "Bearer " + token

        headers["Connection"] = "close"

        headers["Content-Length"] = str(len(raw_body))

        request = (

            "%s %s HTTP/1.1\r\n" % (method, path)

            + "".join("%s: %s\r\n" % item for item in headers.items())

            + "\r\n"

        ).encode("utf-8")

        sock = self._connect()

        try:

            _safe_send(sock, request)

            if raw_body:

                _safe_send(sock, raw_body)

            status, response_headers, payload = _read_response(sock)

        finally:

            sock.close()

        if status < 200 or status >= 300:

            raise HttpError(status, payload)

        return status, response_headers, payload

    def create_recorded_session(

        self,

        video_bytes,

        audio_bytes,

        max_duration_seconds,

        capture,

        email=None,

        meeting_type=None,

    ):

        return self._request(

            "POST",

            "/api/v1/device-sessions",

            self.device_token,

            {

                "device_id": self.device_id,

                "mode": "recorded",

                "max_duration_seconds": max_duration_seconds,

                "capture": capture,

                "files": {

                    "video": {"size_bytes": video_bytes},

                    "audio": {"size_bytes": audio_bytes},

                },

                "email": email,

                "meeting_type": meeting_type,

            },

        )[2]

    def create_recorded_audio_session(

        self,

        audio_bytes,

        max_duration_seconds,

        capture,

        email=None,

        meeting_type=None,

    ):

        return self._request(

            "POST",

            "/api/v1/device-sessions",

            self.device_token,

            {

                "device_id": self.device_id,

                "mode": "recorded",

                "max_duration_seconds": max_duration_seconds,

                "capture": capture,

                "files": {

                    "audio": {"size_bytes": audio_bytes},

                },

                "email": email,

                "meeting_type": meeting_type,

            },

        )[2]

    def create_live_session(self, max_duration_seconds, capture, email=None, meeting_type=None):

        return self._request(

            "POST",

            "/api/v1/device-sessions",

            self.device_token,

            {

                "device_id": self.device_id,

                "mode": "live",

                "max_duration_seconds": max_duration_seconds,

                "capture": capture,

                "email": email,

                "meeting_type": meeting_type,

            },

        )[2]

    def status(self, session_id, session_token):

        return self._request(

            "GET",

            "/api/v1/device-sessions/" + session_id,

            session_token,

        )[2]

    def upload_offset(self, session_id, session_token, slot):

        _, headers, _ = self._request(

            "HEAD",

            "/api/v1/device-sessions/%s/files/%s" % (session_id, slot),

            session_token,

        )

        return int(headers["upload-offset"]), int(headers["upload-length"])

    def upload_file(

        self,

        session_id,

        session_token,

        slot,

        file_path,

        retry_count=5,

        progress=None,

    ):

        offset, expected = self.upload_offset(session_id, session_token, slot)

        local_size = __import__("os").stat(file_path)[6]

        if local_size != expected:

            raise OSError(

                "Local recording size does not match declared upload length"

            )

        buffer = bytearray(UPLOAD_CHUNK_BYTES)

        buffer_view = memoryview(buffer)

        with open(file_path, "rb") as source:

            while offset < expected:

                source.seek(offset)

                chunk_size = min(UPLOAD_CHUNK_BYTES, expected - offset)

                actual = source.readinto(buffer_view[:chunk_size])

                if not actual:

                    raise OSError("Local recording is shorter than declared")

                body = buffer_view[:actual]

                attempts = 0

                while True:

                    try:

                        _, headers, _ = self._request(

                            "PATCH",

                            "/api/v1/device-sessions/%s/files/%s"

                            % (session_id, slot),

                            session_token,

                            body,

                            {

                                "Content-Type": "application/offset+octet-stream",

                                "Upload-Offset": str(offset),

                            },

                        )

                        offset = int(headers["upload-offset"])

                        break

                    except (OSError, HttpError):

                        attempts += 1

                        if attempts > retry_count:

                            raise

                        sleep_ms(min(8000, 500 * (2 ** (attempts - 1))))

                        offset, _ = self.upload_offset(

                            session_id, session_token, slot

                        )

                if progress:

                    progress(slot, offset, expected)

        return offset

    def wait_for_job(

        self,

        session_id,

        session_token,

        timeout_seconds=600,

        poll_seconds=2,

    ):

        started = ticks_ms()

        while ticks_diff(ticks_ms(), started) < timeout_seconds * 1000:

            status = self.status(session_id, session_token)

            if status.get("job_id"):

                return status

            if status.get("state") in ("FAILED", "INTERRUPTED", "EXPIRED"):

                raise OSError("Server rejected media: %s" % status.get("error"))

            time.sleep(poll_seconds)

        raise OSError("Timed out waiting for server job")

    def open_live_stream(self, session_id, session_token):

        path = "/api/v1/device-sessions/%s/stream" % session_id

        sock = self._connect()

        request = (

            "POST %s HTTP/1.1\r\n" % path

            + "Host: %s\r\n" % self.host

            + "Authorization: Bearer %s\r\n" % session_token

            + "Content-Type: application/x-meeting-asr-av\r\n"

            + "X-Meeting-ASR-Protocol: mav1\r\n"

            + "Transfer-Encoding: chunked\r\n"

            + "Connection: close\r\n\r\n"

        ).encode("utf-8")

        _safe_send(sock, request)

        return ChunkedAvStream(sock)

class ChunkedAvStream:

    def __init__(self, sock):

        self.sock = sock

        self.sequence = 0

        self.closed = False

    def write_record(self, record_type, timestamp_ms, payload=b""):

        if self.closed:

            raise OSError("A/V stream is closed")

        raw = av_record(record_type, self.sequence, timestamp_ms, payload)

        self.sequence += 1

        _safe_send(self.sock, ("%x\r\n" % len(raw)).encode("ascii"))

        _safe_send(self.sock, raw)

        _safe_send(self.sock, b"\r\n")

    def finish(self, timestamp_ms):

        if self.closed:

            return None

        self.write_record(AV_END, timestamp_ms)

        _safe_send(self.sock, b"0\r\n\r\n")

        status, headers, payload = _read_response(self.sock)

        self.sock.close()

        self.closed = True

        if status < 200 or status >= 300:

            raise HttpError(status, payload)

        return payload

    def abort(self):

        if not self.closed:

            self.sock.close()

            self.closed = True

"""Record one HD AVI on OpenMV N6 and upload it on the same trusted LAN.

This is an additional workflow. It does not replace ``upload.py`` or the

existing WAV/MJPEG programs. The AVI must contain both video and 16 kHz mono

audio; the server verifies both streams before creating a job.

"""

import gc

import json

import os

import time

import csi

import network

from avi_recorder import AviWriter

from machine import LED

import device_config as config

from meeting_asr_client import MeetingAsrClient

OUTPUT_FILE = "/sdcard/hd_record.avi"

RECORD_SECS = 6

QUALITY = 70

WIDTH = 1280

HEIGHT = 720

WIFI_TIMEOUT_SECONDS = 60

SESSION_SECONDS = max(180, RECORD_SECS + 120)

# 上传时附加的两个业务参数

UPLOAD_EMAIL = "1"

UPLOAD_MEETING_TYPE = "2"

led_red = LED("LED_RED")

led_green = LED("LED_GREEN")

led_blue = LED("LED_BLUE")

I reproduced your slow upload on an N6 here and tracked it down. Three separate things were stacking up, and one of them is genuinely non-obvious:

1. gc.collect() per chunk. Your earlier script called gc.collect() after every 4 KB send. On the N6’s SDRAM-sized heap each collect takes tens of ms, so that alone caps you around 50 KB/s. Your later script fixed this with readinto() into a preallocated buffer — keep that.

2. WiFi power save. Disable it before uploading:

wlan.config(pm=network.WLAN.PM_NONE)

3. The non-obvious one: don’t fine-grain interleave SD reads with socket sends. This is what was still killing throughput even with clean code. Measured on my bench (N6, quiet 2.4 GHz network):

Upload from RAM only (no SD)  : ~600 KB/s, steady
SD read only (no network)     : 6-16 MB/s, no stalls
Upload from SD, 64 KB reads   : ~136 KB/s, multi-second stalls
Upload from SD, 256 KB reads  : ~560 KB/s
Upload from SD, 4 MB reads    : ~612 KB/s

Each part is fast on its own, but alternating small SD reads with sends makes TCP stall (it looks like retransmit-timeout backoff — bursts at full speed, then ~1 s per packet). Reading from the card in 256 KB or larger chunks between sends restores full link speed. We’ll look into the underlying firmware interaction, but the workaround is simple and you want big reads anyway.

Also: keep one connection and one POST for the whole file instead of a PATCH request per 1 MB chunk — each reconnect restarts TCP slow-start and your backoff timer. And use socket.write() rather than send(); write() already loops until everything is sent, so you don’t need safe_send().

Here’s the upload function that hits ~600 KB/s on my bench. Note it sends the file as a plain octet-stream POST (simpler and faster than multipart), so use the matching receiver script below rather than your multipart server:

def upload(path, host, port):
    size = os.stat(path)[6]
    addr = socket.getaddrinfo(host, port)[0][-1]
    s = socket.socket()
    s.connect(addr)
    try:
        s.write(("POST /upload HTTP/1.1\r\n"
                 "Host: %s\r\n"
                 "Content-Type: application/octet-stream\r\n"
                 "X-Filename: %s\r\n"
                 "Content-Length: %d\r\n"
                 "Connection: close\r\n\r\n" % (host, path, size)).encode())
        buf = bytearray(262144)   # >= 256 KB is the important part
        mv = memoryview(buf)
        with open(path, "rb") as f:
            while True:
                n = f.readinto(buf)
                if not n:
                    break
                s.write(mv[:n])
        return s.read(512)        # server reply ("Connection: close" -> EOF)
    finally:
        s.close()

One more expectation-setting note: ~600 KB/s is about the realistic TCP uplink ceiling for the N6’s WiFi. The limit is a roughly fixed cost per packet, not per byte — each 1460-byte segment pays for a copy into the stack, a software TCP checksum, its own SDIO transaction to the radio, and servicing the ACKs coming back on the same bus, which works out to ~2 ms per segment. That’s why raising TCP windows/buffers doesn’t help (the sender can’t produce packets any faster) and why most MicroPython WiFi boards land in the same 0.5-1 MB/s range. With the fixes above, the 3-minute clip that took you 20+ minutes should upload in under 2 minutes.

What we can improve is enabling the H.264 feature on the N6, which will dramatically decrease the clip file size. I plan to have that feature implemented in the next two months.

upload_server.py (3.1 KB)

forum_n6_record_upload.py (3.3 KB)

avi_recorder.py (13.3 KB)

If you are able to use UDP the performance is much higher. Then, with ethernet, there’s much more hardware offload for the CPU with UDP giving an even bigger performance boost.

Follow-up with the deeper “why” on the WiFi TCP throughput limit, since this applies to anyone moving bulk data (video, datasets, OTA files) off a MicroPython board and it’s worth being findable:

The TCP ceiling is a per-packet cost, not a per-byte cost

At ~600 KB/s the N6 is sending about 420 TCP segments per second (1460 bytes each). That means the whole pipeline — Python, lwIP, the WiFi driver, the SDIO bus — spends a roughly fixed ~2.4 ms of work and waiting per segment. Throughput is simply 1460 bytes divided by that per-packet cost. Halve the cost and you double the speed; nothing else moves it.

Where the time per packet goes

  1. Copies and checksum. tcp_write() copies your data into the stack’s buffers, then computes the TCP checksum in software (the radio has no checksum offload). On an 800 MHz Cortex-M55 this is only a few microseconds per packet — this is NOT the bottleneck, which is exactly why a fast CPU doesn’t save you.

  2. The bus transaction. The driver wraps each frame in its transport headers and performs one SDIO transaction per frame, one frame at a time, with no transmit queue depth. Command setup and bus latency are paid per packet.

  3. Flow-control coupling. The host may only transmit when the radio firmware grants it credits, and credit updates arrive via receive interrupts — so transmit pacing also waits on interrupt latency and scheduler hops, per packet.

  4. ACK servicing. Every couple of segments, an ACK comes back: a bus read plus an lwIP input pass, stealing the same serialized bus and CPU time the transmit path needs.

Why bigger TCP windows/buffers don’t help

Windows fix in-flight limits: they matter when RTT x target-rate exceeds the window (long or high-latency pipes). The default window here (~23 KB) at typical LAN RTT (~5 ms) already permits ~4.6 MB/s — about 7x what we actually observe. The pipe is never full; the sender simply can’t produce segments any faster. Growing the send buffer just gives a starved producer a bigger empty queue. We’ve been down this road — window sizes, buffer counts, pool sizes — and it always comes back to per-packet CPU and bus overhead.

The fingerprints of a per-packet limit

  • UDP beats TCP, because there are no ACKs to service — the receive half of the per-packet cost disappears.
  • Downlink (PC → camera) is weaker than uplink, because receiving costs the host more per packet (bus read, stack input, and generating ACKs that compete with incoming data for the same bus), and the MCU can’t backpressure the radio when it falls behind.
  • Throughput barely scales with CPU clock. A Pico W at 133 MHz and the N6 at 800 MHz use the same driver architecture, and 6x the clock buys nowhere near 6x the throughput — bus latency, interrupt overhead, and protocol turnarounds don’t scale with MHz.

What would actually raise the ceiling

Structural driver work, not tuning: batching multiple frames per bus transaction, zero-copy transmit with the checksum folded into the single copy pass, and deeper transmit queueing. To set expectations: the WiFi driver we build on is the same well-proven upstream driver the whole MicroPython ecosystem shares across many boards, and improvements at this level touch its core architecture — they have to be designed and validated for every port that depends on it, not just ours. That’s the right way to do it, but it means these gains land on the timescale of upstream releases, not a quick firmware patch from us. Until then, plan for roughly 0.5-1 MB/s TCP on MicroPython WiFi boards and design around streaming within that budget — which, for what it’s worth, still comfortably carries QVGA MJPEG+audio in real time.

Hi,kwagyeman,"My AI programmer referenced your code, and we successfully improved the transfer speed from 50 KB/s to 100 KB/s. However, with the exact same code, I tried switching from a router LAN connection to my phone’s hotspot—keeping both the N6 and my computer on the same local network—and the speed jumped to 700 KB/s. I’m wondering if this has something to do with my router/network setup on my end.

I’ve attached my modified code for reference. I truly appreciate your help—700 KB/s is already sufficient for our demo requirements. I’ll hold off on further modifications until you release the H.264 module. Really looking forward to that day!"

"""Record one HD AVI on OpenMV N6 and upload it on the same trusted LAN.

This is an additional workflow. It does not replace upload.py or the
existing WAV/MJPEG programs. The AVI must contain both video and 16 kHz mono
audio; the server verifies both streams before creating a job.
"""

import gc
import json
import os
import time

import csi
import network
from avi_recorder import AviWriter
from machine import LED

import device_config as config
from meeting_asr_client import MeetingAsrClient

OUTPUT_FILE = "/sdcard/hd_record.avi"
RECORD_SECS = 6
QUALITY = 70
WIDTH = 1280
HEIGHT = 720
WIFI_TIMEOUT_SECONDS = 60
SESSION_SECONDS = max(180, RECORD_SECS + 120)

上传时附加的两个业务参数

UPLOAD_EMAIL = "你"
UPLOAD_MEETING_TYPE = "好"

led_red = LED("LED_RED")
led_green = LED("LED_GREEN")
led_blue = LED("LED_BLUE")

def led(state):
led_red.off()
led_green.off()
led_blue.off()
if state == "ready":
led_green.on()
elif state == "recording":
led_red.on()
elif state == "network":
led_green.on()
led_blue.on()
elif state == "done":
led_blue.on()
elif state == "error":
led_red.on()
led_green.on()

def connect_same_lan():
led("network")
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not wlan.isconnected():
wlan.connect(config.WIFI_SSID, config.WIFI_PASSWORD)
started = time.ticks_ms()
last_notice = -1
while not wlan.isconnected():
elapsed = time.ticks_diff(time.ticks_ms(), started)
if elapsed >= WIFI_TIMEOUT_SECONDS * 1000:
raise OSError("Wi-Fi connection timed out")
seconds = elapsed // 1000
if seconds and seconds % 5 == 0 and seconds != last_notice:
print("Waiting for Wi-Fi:", seconds, "seconds")
last_notice = seconds
time.sleep_ms(250)
print("Wi-Fi connected; device IP:", wlan.ifconfig()[0])
try:
# 关闭 WiFi 省电:省电模式下发送前要等 radio 唤醒,实测能把吞吐
# 压掉一截;上传场景应始终关掉(开发者建议)。
wlan.config(pm=network.WLAN.PM_NONE)
print("Wi-Fi power save disabled (PM_NONE)")
except (AttributeError, ValueError):
# 固件缺少 PM_NONE 常量时的原始值兜底(开发者给的 0xA11140)
try:
wlan.config(pm=0xA11140)
print("Wi-Fi power save disabled (pm=0xA11140)")
except Exception as error:
print("Could not disable Wi-Fi power save:", error)
except Exception as error:
print("Could not disable Wi-Fi power save:", error)
try:
# 信号强度:<-70 dBm 基本可以断定是环境/距离问题,不是代码问题
print("Wi-Fi RSSI:", wlan.status("rssi"), "dBm")
except Exception:
pass
print("Meeting ASR receiver:", config.SERVER_URL)
return wlan

def record_avi():
led("ready")
print("Initializing the HD camera...")
camera = csi.CSI()
camera.reset()
camera.pixformat(csi.RGB565)
camera.framesize(csi.HD)
camera.snapshot(time=2000)
try:
os.remove(OUTPUT_FILE)
except OSError:
pass

print("Recording starts in 2 seconds...")
time.sleep(2)
writer = AviWriter(
    OUTPUT_FILE,
    width=WIDTH,
    height=HEIGHT,
    quality=QUALITY,
    audio_frequency=16000,
    audio_channels=1,
)
if not writer.record_audio:
    writer.close()
    raise OSError("OpenMV audio capture is unavailable; AVI upload requires audio")
frame_count = 0
started = time.ticks_ms()
led("recording")
try:
    while time.ticks_diff(time.ticks_ms(), started) < RECORD_SECS * 1000:
        writer.add_frame(camera.snapshot())
        frame_count += 1
finally:
    writer.close()

elapsed_ms = max(1, time.ticks_diff(time.ticks_ms(), started))
actual_fps = frame_count * 1000.0 / elapsed_ms
file_size = os.stat(OUTPUT_FILE)[6]
if file_size < 1024:
    raise OSError("AVI recording is missing or incomplete")
print("AVI finalized:", file_size, "bytes")
print("Frames:", frame_count, "actual FPS: %.1f" % actual_fps)
gc.collect()
return actual_fps, file_size

def upload_combined_avi(actual_fps, file_size):
client = MeetingAsrClient(
config.SERVER_URL,
config.DEVICE_ID,
config.DEVICE_TOKEN,
timeout_seconds=60,
)
capture_fps = max(1, min(30, int(actual_fps + 0.5)))
print("Creating a combined-media session...")
session = client._request(
"POST",
"/api/v1/device-sessions",
client.device_token,
{
"device_id": config.DEVICE_ID,
"mode": "recorded",
"max_duration_seconds": SESSION_SECONDS,
"capture": {
"width": WIDTH,
"height": HEIGHT,
"frame_rate": capture_fps,
"jpeg_quality": QUALITY,
"audio_sample_rate_hz": 16000,
"audio_channels": 1,
"audio_format": "s16le",
},
"files": {"media": {"size_bytes": file_size}},
"email": UPLOAD_EMAIL,
"meeting_type": UPLOAD_MEETING_TYPE,
},
)[2]
session_id = session["session_id"]
session_token = session["token"]
print("Session created:", session_id)
print("Server meeting type:", session.get("meeting_type"))
alternate = [False]

def progress(slot, offset, total):
    alternate[0] = not alternate[0]
    led("network" if alternate[0] else "recording")
    print("Upload", slot, offset, "/", total)

# 快路径:单连接整文件 PATCH(消除每 1 MB 重连的 slow-start 重启)。
# 失败时回退到分块断点续传,可靠性不降。
try:
    print("Uploading AVI in a single connection...")
    client.upload_file_single(
        session_id,
        session_token,
        "media",
        OUTPUT_FILE,
        progress=progress,
    )
except Exception as error:
    print("Single-shot upload failed (%s); falling back to chunked upload" % error)
    client.upload_file(
        session_id,
        session_token,
        "media",
        OUTPUT_FILE,
        progress=progress,
    )
print("AVI uploaded; waiting for the server to accept...")
status = wait_for_accepted(client, session_id, session_token)
media = (status.get("files") or {}).get("media") or {}
return {
    "ok": True,
    "job_id": "session_" + session_id,
    "filename": "media.avi",
    "size": media.get("received_bytes", file_size),
    "email": status.get("email"),
    "meeting_type": status.get("client_meeting_type"),
    "state": status.get("state"),
}

def wait_for_accepted(
client,
session_id,
session_token,
timeout_seconds=120,
poll_seconds=2,
):
"""Wait until the receiver marks the session JOB_CREATED (media stored)."""
started = time.ticks_ms()
while time.ticks_diff(time.ticks_ms(), started) < timeout_seconds * 1000:
status = client.status(session_id, session_token)
if status.get("state") == "JOB_CREATED":
return status
if status.get("state") in ("FAILED", "INTERRUPTED", "EXPIRED"):
raise OSError("Server rejected media: %s" % status.get("error"))
time.sleep(poll_seconds)
raise OSError("Timed out waiting for server acceptance")

def main():
try:
connect_same_lan()
actual_fps, file_size = record_avi()
summary = upload_combined_avi(actual_fps, file_size)
led("done")
print(
"上传成功!服务器响应: ok=%s job=%s filename=%s size=%s email=%s meeting_type=%s state=%s"
% (
summary["ok"],
summary["job_id"],
summary["filename"],
summary["size"],
summary["email"],
summary["meeting_type"],
summary["state"],
)
)
print("文件保存在SD卡: " + OUTPUT_FILE)
except Exception as error:
led("error")
print("Same-LAN AVI workflow failed:", error)
print("The AVI remains on the microSD card for retry.")
raise

main()