A pan-and-tilt security camera served over Ethernet by a PIC32MX230F064D with 16 KB of RAM, no RTOS, no heap, and a hand-written TCP/IP stack — ARP, ICMP and TCP/80 are parsed byte by byte from raw Ethernet frames. Video comes from an ESP32-CAM acting as a dumb serial peripheral, and reaches the browser as an MJPEG stream.
The browser on 192.168.60.240 showing the live stream, the servo panning on command, the alarm going off, and the flash LED kicking in as night vision.
demo.mp4
- Web server written from scratch — no lwIP, no Microchip TCP/IP stack.
tcpip.chandles ARP replies, ICMP echo, and the TCP handshake/segmentation for port 80 directly on top of the ENC28J60 driver. - MJPEG live stream (
GET /stream) over a persistentmultipart/x-mixed-replaceconnection, plus single-shot JPEG capture (GET /photo). - Servo pan with manual steps and a continuous auto-sweep, driven entirely from the Timer2 ISR.
- Automatic night vision — an LDR on the ADC triggers the ESP32-CAM's on-board flash LED with hysteresis, with no involvement from the main loop.
- Alarm — an ambulance-style two-tone siren generated by bit-banging a GPIO from the Timer4 ISR, with a synchronised blinking LED.
- Serial command console on UART2 — move the servo and fire the alarm without a browser.
- Single-page dark UI compiled into flash as one C macro (
webpage.h).
| Inside the box | Lid open |
|---|---|
![]() |
![]() |
Left: the breadboard, the ENC28J60 module and the PIC32 board, powered from a bench supply. Right: the lid carries the camera and its servo; everything else lives underneath.
flowchart LR
B["Browser"]
E["ENC28J60<br/>10 Mbps Ethernet"]
P["PIC32MX230F064D<br/>40 MHz · 16 KB RAM"]
C["ESP32-CAM<br/>OV2640 · QQVGA"]
B <-->|"HTTP :80 · MJPEG"| E
E <-->|"SPI1 @ 500 kHz"| P
P <-->|"UART1 @ 250000 baud"| C
P --- S["Servo<br/>OC1 · Timer2 @ 50 Hz"]
P --- L["LDR<br/>AN4 · Timer3 @ 100 Hz"]
P --- Z["Buzzer + LED<br/>Timer4 siren"]
P --- D["Debug console<br/>UART2 @ 9600"]
main.c initialises every peripheral and then does exactly one thing forever: poll the ENC28J60 for
packets and hand them to tcpip.c. The servo, the light sensor and the siren all run from their own
timer interrupts and never touch the main loop.
| File | Responsibility |
|---|---|
main.c |
Peripheral init, network polling loop, periodic ENC28J60 diagnostics |
tcpip.c / .h |
Ethernet frame parsing, ARP, ICMP, TCP/80, MJPEG stream state, 1400-byte segmentation |
http_server.c / .h |
Routes the control endpoints and /status; serves the HTML page |
webpage.h |
The whole UI as one WEBPAGE_HTML macro (HTTP headers included) |
enc28j60.c / .h |
ENC28J60 register/PHY access, bank switching, packet TX/RX, errata B7 workaround |
spi.c / .h |
SPI1 master (mode 0,0) and manual chip-select helpers |
camera.c / .h |
Blocking UART1 request/response driver for the ESP32-CAM |
ADC.c / .h |
Timer3-triggered LDR sampling; hysteresis drives the camera's flash LED |
PWM.c / .h |
OC1 servo positioning and Timer2-driven auto-sweep |
buzzer.c / .h |
Timer4 two-tone siren plus the alarm LED |
UART1.c / .h |
UART1 to the ESP32-CAM, 256-byte circular TX/RX queues |
UART2.c / .h |
UART2 debug console; its RX ISR is also the command interpreter |
Pic32Ini.c / .h |
Device configuration pragmas (oscillator, PLL, watchdog) |
esp32-cam/code/code.ino |
ESP32-CAM sketch — serial only, no WiFi |
- PIC32MX230F064D board
- ENC28J60 Ethernet module (SPI, 3.3 V)
- ESP32-CAM, AI Thinker variant with OV2640
- Micro servo (SG90 class)
- LDR in a voltage divider
- Buzzer, LED + resistor
- USB–serial adapter for the debug console
- PICkit 3 (or equivalent) to program the PIC32
Taken from the source, not from the original design sketch.
| Function | PIC32 pin | Notes |
|---|---|---|
ENC28J60 CS |
RC8 | Plain GPIO, toggled by hand (spi.c) |
ENC28J60 SDO1 (MOSI) |
RC6 | PPS RPC6R = 3 |
ENC28J60 SCK1 |
RB14 | Fixed pin |
ENC28J60 SDI1 (MISO) |
RA1 | PPS SDI1R = 0 |
| Servo signal | RB15 | PPS RPB15R = 5 → OC1 |
| LDR | RB2 / AN4 | Analog input |
| UART1 TX → ESP32 RX | RB7 | PPS RPB7R = 1 |
| UART1 RX ← ESP32 TX | RB13 | PPS U1RXR = 3 |
| UART2 TX → PC | RC9 | PPS RPC9R = 2 |
| UART2 RX ← PC | RB8 | PPS U2RXR = 4 |
| Buzzer | RC5 | Toggled from the Timer4 ISR |
| Alarm LED | RB3 | |
| Night-vision LED | GPIO 4 | On the ESP32-CAM board |
- 8 MHz crystal → ÷2 → PLL ×20 → ÷2 = 40 MHz system clock;
FPBDIV = DIV_8→ 5 MHz PBCLK. - SPI1 at 500 kHz (
SPI1BRG = 4). - Timer2 at 50 Hz (20 ms) — servo PWM base. Pulse range 0.5–2.5 ms (
OC1RS1250–6250) ≈ 180°. A manual step is 20°; the auto-sweep advances 5° every 500 ms (≈18 s end to end). - Timer3 at 100 Hz — ADC auto-sampling trigger.
- Timer4 — siren: alternates a 1201 Hz and a 700 Hz square wave every ~500 ms.
- The ESP32-CAM needs its own 5 V supply; it draws well over what the PIC32 board can source.
Both the MAC and the IP are compile-time constants in main.c:
uint8_t mi_mac[6] = {0x00, 0x14, 0xA5, 0x76, 0x19, 0x3F};
uint8_t mi_ip[4] = {192, 168, 60, 240};There is no DHCP and no gateway logic — put the board on the same subnet as your PC, or edit those two lines. The server listens on port 80.
| Route | Effect |
|---|---|
GET / |
The HTML page from webpage.h |
GET /stream |
MJPEG, multipart/x-mixed-replace;boundary=frame, keep-alive |
GET /photo |
One JPEG with Content-Length; 503 if the capture fails |
GET /status |
{"a":…,"b":…,"c":…,"d":…} — CSS class and text for the light and alarm badges |
GET /left · GET /right |
Step the servo one position |
GET /sweep |
Toggle the auto-sweep |
GET /alarm |
Toggle the siren |
The page polls /status every 2 s from JavaScript; the control buttons are plain fetch() calls.
ARP requests are answered and ICMP echo requests are replied to, so the board pings.
The ESP32-CAM never joins a WiFi network. It is a serial peripheral: it waits for a command byte and answers with a framed JPEG at 250000 baud.
PIC32 → ESP32 'C' capture a frame
'O' / 'F' flash LED on / off
ESP32 → PIC32 0xFF 0xAA │ length (4 B, big-endian) │ JPEG bytes │ 0xFF 0xBB
The JPEG is sent in 128-byte chunks, and the ESP32 checks for pending O/F commands between
chunks so night vision still responds while a frame is in flight. On the PIC32 side,
Camera_CapturarImagen() disables the UART1 RX interrupt and polls U1RXREG directly, writing
straight into cam_img_buf at offset CAM_HEADER_RESERVE, so tcpip.c can build the HTTP headers
in that same buffer and just memmove the JPEG up against them — no second buffer needed.
Frames are QQVGA (160×120) at jpeg_quality = 10, which keeps them under the 4000-byte cap
(CAM_IMG_MAX in camera.h).
Open the project in MPLAB X (XC32 v4.60, PICkit 3) and build. A Makefile is checked in, but it
includes nbproject/Makefile-impl.mk and nbproject/Makefile-variables.mk, which are generated by
the IDE and deliberately gitignored — make will not work on a fresh clone until you have opened
the project in MPLAB X once.
Arduino IDE, board AI Thinker ESP32-CAM, upload esp32-cam/code/code.ino. Requires the
esp32 core (the sketch uses esp_camera.h). Remember to bridge IO0 to GND while flashing.
There is no automated test suite. Flash both boards, then:
- Watch UART2 at 9600 baud — you should see
Inicio MAC. Revision ENC28J60: <n>. A revision of 0 or 255 means the SPI wiring is wrong, and the firmware says so. ping 192.168.60.240- Open
http://192.168.60.240/in a browser.
| What | Where |
|---|---|
| MAC / IP | main.c — mi_mac, mi_ip |
| Light thresholds | ADC.c — LUZ_UMBRAL_ON 462, LUZ_UMBRAL_OFF 562 |
| Max JPEG size | camera.h — CAM_IMG_MAX |
| Servo range and step | PWM.c — OC1_MIN, OC1_MAX, POS_STEP |
| Siren tones | buzzer.c — PR4_HIGH, PR4_LOW |
| The web page itself | webpage.h — must fit in http_response[2900] |
The UART2 RX interrupt doubles as a command interpreter, so you can drive the hardware from a serial terminal with no network at all:
| Key | Action |
|---|---|
R / r |
Pan right |
L / l |
Pan left |
S / s |
Toggle auto-sweep |
A / a |
Toggle the alarm |
Anything else is ignored. The same port carries the firmware's log output.
With 16 KB of RAM, every buffer is accounted for and there is no malloc anywhere:
| Buffer | Size | Where |
|---|---|---|
buffer_red |
1500 B | main.c — Ethernet RX scratch |
tcp_tx_buf |
1500 B | tcpip.c — TX frame assembly |
http_response |
2900 B | tcpip.c — rendered HTTP response |
cam_img_buf |
4150 B | camera.c — 150 B header space + 4000 B JPEG |
One detail worth calling out: the main loop rate-limits its SPI polling when no stream is active
(main.c). Hammering the SPI bus thousands of times a second radiated enough EMI to couple audible
noise into the buzzer line.
/statusclassifies the light with a fixed threshold of 512, while the LED itself uses hysteresis at 462/562. Inside that dead band the badge in the browser can disagree with the actual LED.Camera_CapturarImagen()blocks for roughly 600 ms, so the stream frame rate is low by design.- One TCP client at a time. There is no retransmission, no reassembly of segmented requests, and no connection timeout — a client that disappears without FIN or RST leaves the stream flag set.
- The
Makefilecannot build a fresh clone on its own (see above).
| Marcos Garijo | Software, firmware and the approach to solving the problem — LinkedIn · GitHub |
| Jorge Abella | Hardware selection and the physical build — LinkedIn · GitHub |
Built as a microcontrollers coursework project. Pic32Ini.c comes from a course template. The
original design document is kept at docs/wireframe.md for reference — note
that the final implementation diverges from it.


