---
title: "mlx90640-rp2350-stream"
url: "https://maker.wiznet.io/TheoIm/projects/mlx90640-rp2350-stream/"
markdown_url: "https://maker.wiznet.io/TheoIm/projects/mlx90640-rp2350-stream/md"
type: "UCC: User Created Content"
author: "William Lin"
author_url: "https://github.com/UnreaLin01/mlx90640-rp2350-stream"
editor: "WIZnet"
editor_url: "https://maker.wiznet.io/"
original_author: "William Lin"
original_url: "https://github.com/UnreaLin01/mlx90640-rp2350-stream"
published: "2026-09-23"
language: "en"
hardware: ["WIZnet W6300"]
likes: 0
views: 450
comments: 0
source: "WIZnet Makers (https://maker.wiznet.io/)"
---

# mlx90640-rp2350-stream

> UnreaLin01's mlx90640-rp2350-stream reads an MLX90640 thermal array on a WIZnet W6300-EVB-Pico2 and ships raw sensor data to a PC over USB or UDP.

Original author: William Lin (source: https://github.com/UnreaLin01/mlx90640-rp2350-stream)

## Components

- **WIZnet W6300** x 1 ([docs](https://wiznet.io/products/ethernet-chips/w6300))

WIZnet parts: W6300 ([Datasheet](https://docs.wiznet.io/Product/Chip/Ethernet/W6300/datasheet?utm_source=maker&utm_medium=project&utm_campaign=w6300), [product hub](https://maker.wiznet.io/products/w6300/))

## Article

An MLX90640 is a 32×24 far-infrared array. It does not hand you temperatures — it hands you raw ADC readings plus a calibration EEPROM, and something has to run the conversion. This project makes a deliberate split: the RP2350 only reads the sensor and puts bytes on the wire, and the host PC does the floating-point conversion in numpy.

The reason is stated plainly in the README. The MLX90640 temperature formula involves heavy floating-point work, and doing it on the MCU eats enough time to disturb the rhythm of reading the sensor. Keeping the device dumb also means the host retains the full raw data, so a different algorithm can be applied later without recapturing anything.

Two firmware builds come out of one compile. `mlx_thermal` streams over USB CDC on a single core. `mlx_thermal_udp` streams over Ethernet with the network running on core 1, and turns USB off to save resources. The host viewer is the same program either way — only a `--source` argument changes.

## Why This One Is Worth a Look

Most camera-over-Ethernet projects publish a frame rate and stop. This one publishes the measurement that changed its architecture.

During Ethernet bring-up the project recorded a single missed subpage at initial connection: a 78 ms interval where 32 ms was expected. The cause was identified as the W6300's ARP lookup blocking the sensor read loop. It happened once, and did not recur after the ARP cache filled. The measurement notes concluded that moving network handling to a second core would prevent the same blocking as more services were added.

That is exactly what the next stage did, and the before/after numbers are recorded rather than asserted.

### It is not a bandwidth story, and that is the point

W6300 throughput on RP2350 has already been measured on WIZnet Maker — [94 Mbps by iperf TCP](https://maker.wiznet.io/Grace_Koo/projects/rp2350-sm-w6300-iperf-tcp-throughput---94mbps---https-nexp-tistory-com-4186/), and [80+ Mbps after tuning](https://maker.wiznet.io/Benjamin/projects/how-to-achieve-80-mbps-ethernet-with-w6300-on-rp2350/). This project does not go near that.

Work it out from the repository's own figures. One subpage is 1666 bytes of payload, sent as two packets of 1052 and 670 bytes on the wire. Add the UDP, IP and Ethernet headers — 46 bytes per packet — and one subpage costs about 1814 bytes. At the measured 31.3 subpages per second that is roughly **57 KB/s, about 0.45 Mbps**.

So the link is running at under half a percent of what the chip has been shown to do, and frames were still being dropped. The cost was not throughput. It was that one ARP resolution landed inside a 32 ms window that had no room in it.

That is a useful thing to have written down, because a thermal array is exactly the sensor where nobody would think to look at the network. 768 pixels is nothing. The pressure comes from the read cadence, not the data rate.

## How It Works

```
  MLX90640 (32x24 FIR array)
        |
        |  I2C  (4 wires: VCC, GND, SDA, SCL)
        v
  +--------------------------------------------+
  |  W6300-EVB-Pico2   (RP2350A, Cortex-M33)   |
  |                                            |
  |  Core 0: I2C read -> packet assembly       |
  |             |                              |
  |             |  queue_t, 16 frames (~0.25s) |
  |             v                              |
  |  Core 1: net_task -> sendto()              |
  |                        |                   |
  +------------------------|-------------------+
                           |  W6300 (QSPI)
                           v
                  UDP over Ethernet
                           |
                           v
                  Host PC: numpy -> temperature -> heatmap
```

Core 0 never waits on the network. `transport_send()` queues a packet and returns; core 1 pulls it out and calls `sendto()`. If that call has to wait for ARP, core 0 keeps reading the sensor on schedule.

### One packet format for both transports

USB CDC and UDP share identical framing, which is why switching transports needs no host change.

| Field | Value |
| --- | --- |
| Magic | `MLXT` (0x4D 0x4C 0x58 0x54) |
| Header | 28 bytes fixed — magic, version, type, subpage, part, part_count, reserved, payload_len, seq, timestamp_us |
| Max payload | 1024 bytes per segment |
| Max packet | 1052 bytes |
| One subpage | 833 uint16 = 1666 bytes, split into 1024 + 642 |
| Integrity | CRC-32, matches `zlib.crc32` (poly 0xEDB88320) |
| Endianness | Little-endian throughout |

The device carries a microsecond timestamp in every packet, which is what makes the latency figures below possible: the host compares the device's own stamp against arrival time rather than guessing.

## What the W6300 Actually Does

The firmware talks to the chip through WIZnet's ioLibrary socket API. `firmware/src/net.c` includes `socket.h`, `wizchip_conf.h`, `w6300.h` and `dhcp.h`, and calls `wizchip_initialize()`, `DHCP_init()`, `getSn_SR()`. There is no lwIP and no MACRAW path in this project.

**What it does**

- Runs the UDP transport in hardware. The firmware calls `sendto()` on a socket; the chip handles the rest.

- Runs DHCP through ioLibrary's `dhcp.h`, falling back to a static address if the lease does not arrive.

- Resolves ARP for the host. This is the one call that can block, which is why it sits on core 1.

- Reports socket state, which the firmware reads back to drive the status LED.

**What it does not do**

- It does not touch the sensor. The MLX90640 is on I2C to the RP2350, not to the W6300.

- It does not convert temperatures. Nothing on the device does — that is the host's job by design.

- It does not carry the USB path. The USB build and the Ethernet build are separate firmware images, and the Ethernet build disables USB entirely.

### This is not a dual-path or failover design

Having a USB build and an Ethernet build is easy to read as redundancy. It is not. They are two firmware images, only one is flashed at a time, and the Ethernet image turns USB off to reclaim resources. USB here is a CDC serial link, not a second network path.

## Why the Network Moved to the Second Core

The sensor read loop is the fixed thing. At 32 subpages per second a subpage arrives every 32 ms and takes 17.51 ms to read out. Anything that blocks inside that window costs a frame.

On a single core, `sendto()` was inside that window. The Ethernet bring-up measurement caught it: one subpage lost at connection time, a 78 ms gap instead of 32 ms, traced to the W6300 resolving ARP for a host it had not spoken to yet.

Moving the transmit path to core 1 turns that into a queue push. The two cores communicate through a hardware-protected `queue_t` holding 16 frames, about a quarter second of buffer, plus atomic flags such as `peer_known`.

## Measured Results

**Single core versus dual core** (65 s run, recorded in `docs/dual_core.md`)

| Metric | Single core (M7 baseline) | Network on core 1 |
| --- | --- | --- |
| Subpages missed during connection | 1–2 (ARP wait) | **0** |
| Packet loss / CRC errors / sequence gaps | 0 | 0 |
| Latency jitter, median | 3.68 ms | **2.24 ms** |
| Latency jitter, 99th percentile | 59.0 ms | **6.83 ms** |
| Latency jitter, maximum | 129.3 ms | **32.9 ms** |
| Subpage read time | 17.482 ms | 17.51 ms (+0.03 ms — the two cores share the bus) |
| Sampling rate | 31.28 Hz | 31.30 Hz |

Read the third row first. The 99th percentile drops from 59.0 ms to 6.83 ms and the maximum from 129.3 ms to 32.9 ms — roughly nine times and four times better. The median barely moves, which is the signature of a tail problem rather than a throughput problem.

The price is 0.03 ms of extra read time, and the document names the cause: with the network on core 1, the two cores now share the bus. Sampling rate is essentially unchanged at 31.28 against 31.30 Hz.

**Sustained UDP run** (300 s, recorded in `docs/m7_udp.md`)

| Metric | Result |
| --- | --- |
| Subpages transmitted | 9,391 |
| Packet loss | 0% over 300 s |
| CRC errors, sequence gaps, incomplete frames | none |
| Rate, device side / host side | 31.3 Hz / 31.29 Hz |
| USB baseline for comparison | 31.296 Hz |

This run is the single-core baseline in the table above. Its median was close to USB — 3.68 ms against 3.01 ms — but the tail ran to 59.0 ms at the 99th percentile and 129.3 ms at maximum. `m7_udp.md` attributes that spread to the host sitting on Wi-Fi behind the router rather than to the device or the W6300. The path under test was Ethernet, then router, then Wi-Fi.

### The two documents do not fully agree, and that is worth saying

Taken on its own, the Wi-Fi explanation is reasonable. But `dual_core.md` uses these same figures as its single-core baseline and brings the maximum down to 32.9 ms by moving the transmit call to core 1 — the host's network path unchanged.

If the tail were purely the Wi-Fi hop, changing which core calls `sendto()` could not have cut it by a factor of four. The more defensible reading is that both contribute: the host's wireless hop sets a floor on the spread, and single-core blocking was adding to it on top. Quoting the 129.3 ms as "just Wi-Fi" understates what the architecture change actually bought.

Neither document claims to have separated the two, and the runs were not instrumented to do so. Worth flagging rather than resolving here.

### Rate limits are stated, not implied

The sensor rate is selectable from 0.5 Hz to 32 Hz by changing `SENSOR_RATE_32HZ` in `firmware/src/main.c`. Lower rates are quieter. **64 Hz does not work** — the readout does not fit in the interval. A project that publishes the rate it cannot reach is easier to trust on the ones it can.

## Where You Could Use This

The pattern here is a slow sensor with an expensive conversion, streamed raw over a wired link and processed off-device. That shape recurs:

- **Electrical panel and busbar monitoring** — a fixed thermal view of a cabinet, where the host keeps history and sets thresholds.

- **Machine and bearing temperature watch** — wired matters because the location is metallic and noisy.

- **Cold chain and process ovens** — raw data retention lets calibration be re-applied after the fact.

- **Test benches** — the microsecond timestamp in each packet makes it possible to line thermal frames up against other instruments.

- **Teaching and evaluation rigs** — the USB build needs no network at all, so the same hardware works on a bench without infrastructure.

## Technical Notes

- **GPIO15 through GPIO22 are taken by the onboard W6300.** The README marks this as fixed in hardware and not changeable. Plan expansion around it.

- **GP6 and GP7 are timing outputs.** GP6 stays high while the sensor is being read; GP7 toggles on every new subpage. They exist for a scope or logic analyzer and can be left unconnected.

- **GP25 LED encodes state by blink rate.** Once per second is normal; about five times per second means sensor init failed; about three times per second means the onboard network chip is not responding. Note that an unplugged cable does *not* change the LED — it stays at once per second while the host finds nothing.

- **Host discovery order:** try the address cached in `host/.board_address`, then broadcast search, then lock to unicast and cache the new address. The stated reason for limiting broadcast to discovery is that Wi-Fi does not retransmit broadcast frames, so keeping the stream on unicast avoids the problem entirely.

- **DHCP with fallback:** if no lease arrives within 15 seconds the board falls back to `192.168.1.200`.

- **Temperature math is cross-checked.** The numpy implementation and Melexis's official C library agree to within 0.04 mK, roughly ten thousand times smaller than the sensor's own noise. The comparison is recorded in `docs/method_b_compare.md`.

- **Pinned toolchain:** Pico SDK 2.3.1, arm-none-eabi-gcc 15.2.Rel1, CMake 4.3.4, Ninja 1.13.2, picotool 2.3.1. Versions are fixed in `scripts/env.ps1` so a different CMake on the system PATH cannot be picked up by mistake.

- **Vendor code is unmodified.** `third_party/` holds WIZnet-PICO-C with ioLibrary_Driver, the Melexis MLX90640 library and SEGGER RTT, all left as-is.

## Before You Build It

Things worth knowing before committing time, based on what the repository does and does not cover.

- **The README is in Traditional Chinese.** There is no English version. The code, comments and measurement documents are readable without it, but the setup walkthrough is not.

- **The toolchain is Windows and PowerShell.** Build, flash and RTT are `.ps1` scripts, and the host environment is created with `py -3.13 -m venv`. A Linux or macOS path is not provided.

- **UDP is the only network transport.** There is no TCP server, no HTTP endpoint and no browser view. The heatmap lives in a Python program on the host, so this is not a drop-in web camera.

- **No on-device temperature.** If you want the device itself to report degrees — to an MQTT broker, say — that conversion does not exist here and is deliberately excluded.

- **The latency tail has two contributors and the repository does not separate them.** `m7_udp.md` attributes the 129.3 ms maximum to the host's Wi-Fi hop, while `dual_core.md` cuts the same figure to 32.9 ms by moving the send to core 1 over the same network. Expect your own numbers to depend on both the host's network path and which core transmits.

- **The repository is new.** 22 commits, no stars, no releases, no CI. Treat it as a well-documented reference implementation rather than a maintained product.

- **The author discloses how it was built.** The README states that most of the project was produced with Anthropic's Claude running on the author's machine, with the author handling hardware preparation, the parts Claude could not do, and decisions requiring human judgment. The hardware measurements were taken with a SEGGER J-Link and a Saleae Logic 8, and the bench photo is included in the repository. It is noted here because the project says so; the measurement records and waveform captures are present either way.

## Related Maker Content

Three W6300 + RP2350 entries already on WIZnet Maker frame this one, and together they map the board's limits from different directions.

[W6300 Iperf TCP Throughput — 94 Mbps measured](https://maker.wiznet.io/Grace_Koo/projects/rp2350-sm-w6300-iperf-tcp-throughput---94mbps---https-nexp-tistory-com-4186/)
*Establishes the ceiling this project runs nowhere near.*
Grace's iperf run puts a number on what the link can do. The thermal stream uses about 0.45 Mbps. Reading the two together is what makes this project's finding legible: the constraint was never the pipe.

[How to Achieve 80+ Mbps Ethernet with W6300 on RP2350?](https://maker.wiznet.io/Benjamin/projects/how-to-achieve-80-mbps-ethernet-with-w6300-on-rp2350/)
*The tuning side of the same board.*
Benjamin's write-up is about getting throughput up. This one is about a workload where throughput was already free and the scarce resource was an uninterrupted 32 ms window. Same chip, opposite bottleneck.

[ArduCAM × W6300-EVB-Pico2 High-Speed JPEG Streaming](https://maker.wiznet.io/TheoIm/projects/arducam--w6300-evb-pico2-high-speed-jpeg-streaming-project-c-c) and [Real-Time Video Streaming with W6300 + RP2350](https://maker.wiznet.io/TheoIm/projects/real-time-video-streaming-with-w6300-rp2350--open-source-project/)
*Same board, visible light, bandwidth-bound.*
Both stream images where the frame is large and the link has to keep up. A thermal array inverts that: 768 pixels is nothing to send, but the read cadence is rigid. Putting the visible-light and thermal cases next to each other shows the two failure modes a camera-over-Ethernet design can have.

### The gap this fills

Camera-over-Ethernet entries on WIZnet Maker are almost all visible-light, and almost all of them are bandwidth stories. Thermal imaging brings the opposite constraint: the array is tiny, the frame rate is low, and the expensive parts are the conversion math and the read cadence. It also makes the timing argument unusually clean — at 32 ms per subpage, whether a transmit call blocks is directly visible as a missing frame, and this project measured exactly that.

## FAQ

**Is this hardware TCP/IP offload, or a software stack on the MCU?**
Offload. `firmware/src/net.c` includes `socket.h`, `wizchip_conf.h`, `w6300.h` and `dhcp.h` and drives the chip through WIZnet's ioLibrary socket API. There is no lwIP and no MACRAW path in the repository.

**USB and Ethernet are both supported. Is this a dual-path or failover design?**
No. They are separate firmware images, one is flashed at a time, and the Ethernet image disables USB. USB is a CDC serial link, not a second network path.

**If the link has 94 Mbps of headroom, how can a frame be dropped?**
Because the cost was time, not bandwidth. A subpage has to be read every 32 ms and the readout alone takes 17.51 ms. One ARP resolution landing inside the remainder is enough to push the next read past its slot. Throughput and jitter are different budgets.

**Why not compute temperature on the RP2350? It is a Cortex-M33.**
The README's reason is timing, not capability. The MLX90640 conversion is heavy floating-point work, and running it on the MCU costs enough time to disturb the sensor read rhythm. Keeping the device raw also preserves the full data for later reprocessing.

**Can it reach 64 Hz?**
No. The repository states that the readout does not fit in the interval at 64 Hz. The usable range is 0.5 Hz to 32 Hz, and 32 subpages per second is roughly 16 full frames per second.

**Does the 129 ms maximum latency mean the W6300 stalls?**
Not by itself, and the repository gives two partial answers. `m7_udp.md` attributes the tail to the host's Wi-Fi hop, and the device-side rate held at 31.3 Hz with zero packet loss over 300 seconds. But `dual_core.md` treats the same run as its single-core baseline and reaches 32.9 ms maximum by moving the transmit call to core 1, with the host's path unchanged. A pure Wi-Fi explanation cannot account for a fourfold improvement from a firmware change, so both effects are likely present. The repository does not separate them.

**Can I reuse the pins the W6300 occupies?**
No. GPIO15 through GPIO22 are wired to the onboard W6300 on the W6300-EVB-Pico2 and the README marks this as fixed in hardware.

## Verified Scope and Sources

| Source | Checked | What was confirmed |
| --- | --- | --- |
| [Repository root and README](https://github.com/UnreaLin01/mlx90640-rp2350-stream) | 2026-09-29 | Board is W6300-EVB-Pico2 (RP2350A + W6300); two firmware builds; host-side temperature conversion and the stated reason; GPIO15–GPIO22 occupied by the onboard W6300; GP6/GP7 timing pins; GP25 LED states; DHCP with 15 s fallback to 192.168.1.200; discovery order and the Wi-Fi broadcast rationale; sensor rate range 0.5–32 Hz and that 64 Hz is unusable; pinned toolchain versions; MIT license; author's disclosure about how the project was built |
| [`firmware/src/net.c`](https://github.com/UnreaLin01/mlx90640-rp2350-stream/blob/main/firmware/src/net.c) | 2026-09-29 | ioLibrary socket API confirmed by include list (`socket.h`, `wizchip_conf.h`, `w6300.h`, `dhcp.h`, `wizchip_spi.h`) and calls to `wizchip_initialize()`, `DHCP_init()`, `getSn_SR()`. No lwIP or MACRAW. |
| [`docs/protocol.md`](https://github.com/UnreaLin01/mlx90640-rp2350-stream/blob/main/docs/protocol.md) | 2026-09-29 | 28-byte header, `MLXT` magic, 1024-byte max segment, 1052-byte max packet, 833 uint16 (1666 bytes) per subpage split 1024 + 642, CRC-32 matching zlib, little-endian, identical framing for USB and UDP |
| [`docs/dual_core.md`](https://github.com/UnreaLin01/mlx90640-rp2350-stream/blob/main/docs/dual_core.md) | 2026-09-29 | 65 s run, UDP, debugger attached. Single core (labelled M7 in the source table): 1–2 subpages missed on connection, latency 3.68 / 59.0 / 129.3 ms, read 17.482 ms, 31.28 Hz. Dual core: 0 missed, latency 2.24 / 6.83 / 32.9 ms, read 17.51 ms (+0.03 ms, attributed to the two cores sharing the bus), 31.30 Hz. Zero packet loss, CRC errors and sequence gaps in both. 16-frame `queue_t` (~0.25 s) |
| [`docs/m7_udp.md`](https://github.com/UnreaLin01/mlx90640-rp2350-stream/blob/main/docs/m7_udp.md) | 2026-09-29 | 300 s run: 9,391 subpages, 0% loss, no CRC errors or sequence gaps; 31.3 Hz device / 31.29 Hz host against 31.296 Hz USB baseline; UDP latency 3.68 / 59.0 / 129.3 ms, which this document attributes to the host's Wi-Fi hop; the one missed subpage at connection (78 ms interval) traced to W6300 ARP resolution, and the recommendation to move the network to a second core. `dual_core.md` later reuses these latency figures as its single-core baseline, which is the tension noted in the body. |
| [`docs/method_b_compare.md`](https://github.com/UnreaLin01/mlx90640-rp2350-stream/blob/main/docs/method_b_compare.md) | 2026-09-29 | numpy implementation and Melexis official C library agree within 0.04 mK |
| [W6300 product documentation](https://docs.wiznet.io/Product/Chip/Ethernet/W6300) | 2026-09-29 | W6300 as a hardwired TCP/IP controller with a QSPI host interface, which is the part the ioLibrary socket calls drive |

### Verification limits

**What is established.** Everything above comes from the repository's own code and measurement documents. The ioLibrary path was confirmed by reading `net.c` directly rather than inferred from the board name.

**What is calculated, not measured.** The 0.45 Mbps figure is arithmetic from the repository's stated packet sizes and measured rate — 1666 bytes of payload in two packets, plus 46 bytes of UDP/IP/Ethernet header each, at 31.3 subpages per second. It was not measured on the wire, and the comparison against 94 Mbps is against someone else's iperf TCP result on the same board, not a like-for-like UDP benchmark.

**What is not established.** The hardware was not built or run here, so no figure was independently reproduced. All timing and loss numbers are the author's measurements on the author's bench, and the latency tail in particular depends on that network path. The repository is new — 22 commits, no releases, no CI — so long-term behaviour is unknown, and the Windows/PowerShell toolchain was not tested on another platform.

---

The useful thing in this repository is not the thermal image. It is the sequence: measure, find one missed frame, name the cause, change the structure, measure again, and write both columns down. Trading 0.03 ms of read time for zero dropped subpages and a 99th-percentile jitter of 6.83 ms instead of 59.0 ms is the kind of argument you can only make with figures on both sides — and it only becomes interesting once you know the link had two hundred times more room than it was using.

---

Source: https://maker.wiznet.io/TheoIm/projects/mlx90640-rp2350-stream/
