1. Interface and Application Programming
I plan to implement an HTTP API service on the device, which can then be accessed via a web browser on a PC. The device is controlled over the HTTP protocol. The hardware used is the concurrent-version development board I designed in Week 8. The software is developed using both ESP-IDF and Arduino IDE.
The board shown above is the SmartGlassEV1, which I designed in Week 8. Its primary purpose is to connect the XIAO ESP32S3 development board to a screen and a speaker.
1.1 Image Capture
The device captures images and uploads them to a web server via HTTP. The server then displays the images on a web page.
1.1.1 Device-Side Implementation
The firmware is developed using ESP-IDF. It utilizes the ESP32-CAM module to capture images, encodes them in MJPEG format, and uploads them to the web server over HTTP.
#include <esp_log.h>
#include <esp_system.h>
#include <nvs_flash.h>
#include <sys/param.h>
#include <string.h>
#include <driver/i2c.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_camera.h"
#include "esp_http_server.h"
#include "esp_timer.h"
#include "iperf_cmd.h"
#include "console_wifi.h"
#define PART_BOUNDARY "123456789000000000000987654321"
static const char *_STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY;
static const char *_STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n";
static const char *_STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";
#define TAG "main"
esp_err_t jpg_stream_httpd_handler(httpd_req_t *req)
{
camera_fb_t *fb = NULL;
esp_err_t res = ESP_OK;
size_t _jpg_buf_len;
uint8_t *_jpg_buf;
char *part_buf[64];
static int64_t last_frame = 0;
if (!last_frame)
{
last_frame = esp_timer_get_time();
}
res = httpd_resp_set_type(req, _STREAM_CONTENT_TYPE);
if (res != ESP_OK)
{
return res;
}
while (true)
{
fb = esp_camera_fb_get();
if (!fb)
{
ESP_LOGE(TAG, "Camera capture failed");
res = ESP_FAIL;
break;
}
if (fb->format != PIXFORMAT_JPEG)
{
ESP_LOGI(TAG, "Converting JPEG...");
bool jpeg_converted = frame2jpg(fb, 80, &_jpg_buf, &_jpg_buf_len);
if (!jpeg_converted)
{
ESP_LOGE(TAG, "JPEG compression failed");
esp_camera_fb_return(fb);
res = ESP_FAIL;
}
}
else
{
_jpg_buf_len = fb->len;
_jpg_buf = fb->buf;
}
if (res == ESP_OK)
{
res = httpd_resp_send_chunk(req, _STREAM_BOUNDARY, strlen(_STREAM_BOUNDARY));
}
if (res == ESP_OK)
{
size_t hlen = snprintf((char *)part_buf, 64, _STREAM_PART, _jpg_buf_len);
res = httpd_resp_send_chunk(req, (const char *)part_buf, hlen);
}
if (res == ESP_OK)
{
res = httpd_resp_send_chunk(req, (const char *)_jpg_buf, _jpg_buf_len);
}
if (fb->format != PIXFORMAT_JPEG)
{
free(_jpg_buf);
}
esp_camera_fb_return(fb);
if (res != ESP_OK)
{
break;
}
int64_t fr_end = esp_timer_get_time();
int64_t frame_time = fr_end - last_frame;
last_frame = fr_end;
frame_time /= 1000;
ESP_LOGI(TAG, "MJPG: %luKB %lums (%.1ffps)", (uint32_t)(_jpg_buf_len / 1024), (uint32_t)frame_time, 1000.0 / (uint32_t)frame_time);
}
last_frame = 0;
return res;
}
#define CAM_PIN_PWDN -1
#define CAM_PIN_RESET -1
#define CAM_PIN_VSYNC 38
#define CAM_PIN_HREF 47
#define CAM_PIN_PCLK 13
#define CAM_PIN_XCLK 10
#define CAM_PIN_SIOD 40
#define CAM_PIN_SIOC 39
#define CAM_PIN_D0 15
#define CAM_PIN_D1 17
#define CAM_PIN_D2 18
#define CAM_PIN_D3 16
#define CAM_PIN_D4 14
#define CAM_PIN_D5 12
#define CAM_PIN_D6 11
#define CAM_PIN_D7 48
static camera_config_t camera_config = {
.pin_pwdn = CAM_PIN_PWDN,
.pin_reset = CAM_PIN_RESET,
.pin_xclk = CAM_PIN_XCLK,
.pin_sccb_sda = CAM_PIN_SIOD,
.pin_sccb_scl = CAM_PIN_SIOC,
.pin_d7 = CAM_PIN_D7,
.pin_d6 = CAM_PIN_D6,
.pin_d5 = CAM_PIN_D5,
.pin_d4 = CAM_PIN_D4,
.pin_d3 = CAM_PIN_D3,
.pin_d2 = CAM_PIN_D2,
.pin_d1 = CAM_PIN_D1,
.pin_d0 = CAM_PIN_D0,
.pin_vsync = CAM_PIN_VSYNC,
.pin_href = CAM_PIN_HREF,
.pin_pclk = CAM_PIN_PCLK,
.xclk_freq_hz = 20000000,
.ledc_timer = LEDC_TIMER_0,
.ledc_channel = LEDC_CHANNEL_0,
.frame_size = FRAMESIZE_VGA,
.jpeg_quality = 12, // 0-63, for OV series camera sensors, lower number means higher quality
.fb_count = 2, // When jpeg mode is used, if fb_count more than one, the driver will work in continuous mode.
.fb_location = CAMERA_FB_IN_PSRAM,
.grab_mode = CAMERA_FB_IN_PSRAM,
};
void app_main(void)
{
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_err_t ret = nvs_flash_init(); // Initialize NVS
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND)
{
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(esp_camera_init(&camera_config));
sensor_t *sensor = esp_camera_sensor_get();
// sensor->set_vflip(sensor, 1);
sensor->set_hmirror(sensor, 1);
httpd_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
/* Use the URI wildcard matching function in order to
* allow the same handler to respond to multiple different
* target URIs which match the wildcard scheme */
config.uri_match_fn = httpd_uri_match_wildcard;
ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port);
ESP_ERROR_CHECK(httpd_start(&server, &config));
/* URI handler for getting uploaded files */
httpd_uri_t stream = {
.uri = "/stream", // Match all URIs of type /path/to/file
.method = HTTP_GET,
.handler = jpg_stream_httpd_handler,
.user_ctx = NULL // Pass server data as context
};
httpd_register_uri_handler(server, &stream);
uint32_t frame_time = 0;
// Initialize console REPL
ESP_ERROR_CHECK(console_cmd_init());
ESP_ERROR_CHECK(console_cmd_wifi_register());
// start console REPL
ESP_ERROR_CHECK(console_cmd_start());
}
1.1.2 Web Interface Implementation
I asked ChatGPT to write the HTML code for the webpage, and it gave me the following code:
Help me write a good-looking webpage that displays JPEG stream images. You should include an input field where the user can enter the JPEG stream URL.
\#define PART\_BOUNDARY "123456789000000000000987654321"
static const char \*\_STREAM\_CONTENT\_TYPE = "multipart/x-mixed-replace;boundary=" PART\_BOUNDARY;
static const char \*\_STREAM\_BOUNDARY = "\r\n--" PART\_BOUNDARY "\r\n";
static const char \*\_STREAM\_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MJPEG Stream Viewer</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 flex items-center justify-center min-h-screen p-4">
<div class="bg-white p-6 rounded-xl shadow-md w-full max-w-2xl">
<h1 class="text-2xl font-bold text-center mb-6">MJPEG Stream Viewer</h1>
<div class="flex gap-2 mb-4">
<input
id="streamUrl"
type="text"
placeholder="Enter MJPEG Stream URL (e.g. http://192.168.x.x:81/stream)"
class="flex-1 p-3 border border-gray-300 rounded text-sm"
/>
<button
onclick="startStream()"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded"
>
Load
</button>
</div>
<div class="border rounded overflow-hidden shadow-inner">
<img
id="streamImage"
class="w-full max-h-[600px] object-contain bg-black"
alt="Stream will appear here"
/>
</div>
</div>
<script>
function startStream() {
const input = document.getElementById("streamUrl");
const img = document.getElementById("streamImage");
const url = input.value.trim();
if (!url) {
alert("Please enter a valid MJPEG stream URL.");
return;
}
img.src = url;
}
</script>
</body>
</html>
1.1.3 Demonstration
- The device successfully connects to Wi-Fi
- The HTTP service starts successfully on the device
- Open the following web page in your browser:
MJPEG VIDEO. Enter the device’s IP address and click
“Load” to view the live video feed from the device.
1.2 Radio Station Controller
The device is controlled via the HTTP protocol to switch radio stations and adjust the volume. A simple web interface is used to send control commands.
1.2.1 Device-Side Implementation
The firmware is developed using the Arduino IDE. Audio is played through the I2S interface, and the device responds to HTTP requests for control from the web page.
#include <Arduino.h>
#include <Preferences.h>
#include <WiFi.h>
#include <WebServer.h>
#include "Audio.h"
#define I2S_DOUT 1
#define I2S_BCLK 2
#define I2S_LRC 3
Preferences pref;
Audio audio;
WebServer server(80);
String ssid = "********";
String password = "******";
String stations[] ={
"http://vis.media-ice.musicradio.com/CapitalMP3",
"http://livestream.1766.today:1769/live1.mp3",
"http://livestream.1766.today:1781/live2.mp3",
};
uint8_t max_volume = 100;
uint8_t max_stations = sizeof(stations) / sizeof(stations[0]);
uint8_t cur_station = 0;
uint8_t cur_volume = 50;
void setup() {
Serial.begin(115200);
WiFi.begin(ssid.c_str(), password.c_str());
while (WiFi.status() != WL_CONNECTED) {
delay(2000);
Serial.print(".");
}
Serial.println("Connected to WiFi");
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
audio.setVolume(cur_volume);
audio.connecttohost(stations[cur_station].c_str());
// HTTP Server
server.on("/", HTTP_GET, []() {
String html = "<html><body>";
html += "<h1>Web Radio Controller</h1>";
html += "<h3>Station: " + String(cur_station + 1) + "</h3>";
html += "<h3>Volume: " + String(cur_volume) + "</h3>";
html += "<button onclick=\"location.href='/station/up'\">Next Station</button><br><br>";
html += "<button onclick=\"location.href='/station/down'\">Previous Station</button><br><br>";
html += "<button onclick=\"location.href='/volume/up'\">Volume Up</button><br><br>";
html += "<button onclick=\"location.href='/volume/down'\">Volume Down</button>";
html += "</body></html>";
server.send(200, "text/html", html);
});
// volume control
server.on("/volume/up", HTTP_GET, []() {
if (cur_volume < max_volume) {
cur_volume++;
audio.setVolume(cur_volume);
pref.putShort("volume", cur_volume);
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/volume/down", HTTP_GET, []() {
if (cur_volume > 0) {
cur_volume--;
audio.setVolume(cur_volume);
pref.putShort("volume", cur_volume);
}
server.sendHeader("Location", "/");
server.send(303);
});
// Radio Station Control
server.on("/station/up", HTTP_GET, []() {
if (cur_station < max_stations - 1) {
cur_station++;
audio.connecttohost(stations[cur_station].c_str());
pref.putShort("station", cur_station);
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/station/down", HTTP_GET, []() {
if (cur_station > 0) {
cur_station--;
audio.connecttohost(stations[cur_station].c_str());
pref.putShort("station", cur_station);
}
server.sendHeader("Location", "/");
server.send(303);
});
server.begin();
}
void loop() {
server.handleClient();
audio.loop();
}
1.2.2 Web Interface
I used ChatGPT to help generate the web interface code. Below is the sample code it provided:
I have the following APIs to control a radio. Please help me create a nice-looking web page to control it. Make sure to include a field for entering the device address:
/volume/up
/volume/down
/station/up
/station/down
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Radio Controller</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 flex items-center justify-center min-h-screen">
<div class="bg-white shadow-xl rounded-2xl p-8 max-w-sm w-full">
<h1 class="text-2xl font-bold text-center mb-6">Radio Controller</h1>
<div class="mb-4">
<label for="deviceAddress" class="block text-sm font-medium text-gray-700">Device Address</label>
<input id="deviceAddress" type="text" placeholder="http://192.168.1.100" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<button onclick="sendCommand('volume/up')" class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 rounded-lg">Vol +</button>
<button onclick="sendCommand('volume/down')" class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 rounded-lg">Vol -</button>
<button onclick="sendCommand('station/up')" class="bg-green-500 hover:bg-green-600 text-white font-semibold py-2 rounded-lg">Next</button>
<button onclick="sendCommand('station/down')" class="bg-green-500 hover:bg-green-600 text-white font-semibold py-2 rounded-lg">Prev</button>
</div>
<div id="status" class="text-center text-sm text-gray-600"></div>
</div>
<script>
function sendCommand(command) {
const address = document.getElementById("deviceAddress").value;
const status = document.getElementById("status");
if (!address) {
status.textContent = "Please enter a valid device address.";
return;
}
fetch(`${address}/${command}`)
.then(response => {
if (response.ok) {
status.textContent = `Command "${command}" sent successfully.`;
} else {
status.textContent = `Failed to send "${command}".`;
}
})
.catch(error => {
status.textContent = `Error: ${error.message}`;
});
}
</script>
</body>
</html>
1.2.3 Demonstration
- The device successfully connects to Wi-Fi
- Open the following web page in your browser:
Radio Controller. Enter the device’s IP address and
click “Load” to view the live video feed from the device.