零知开源——ESP32驱动OV7670摄像头实现简易照相机系统

 ✔零知IDE 是一个真正属于国人自己的开源软件平台,在开发效率上超越了Arduino平台并且更加容易上手,大大降低了开发难度。零知开源在软件方面提供了完整的学习教程和丰富示例代码,让不懂程序的工程师也能非常轻而易举的搭建电路来创作产品,测试产品。快来动手试试吧!

✔访问零知实验室,获取更多实战项目和教程资源吧!

www.lingzhilab.com

目录

一、硬件接线部分

1.1 硬件清单

1.2 接线方案

1.3 具体接线图

1.4 连接实物图

二、代码解释部分

2.1 核心代码结构

2.2 摄像头初始化

2.3 WebSocket图像传输

2.4 图像数据采集与处理

2.5 完整代码

三、OV7670摄像头模块工作原理

3.1 寄存器配置

3.2 输出图像数据时序

四、项目结果演示

4.1 零知IDE操作

4.2 效果展示

4.3 演示视频

五、常见问题解答

Q1:视频流卡顿严重怎么办?

Q2:拍摄的照片色彩失真怎么办?

Q3:如何提高图像质量?


(1)项目概述

        本项目基于零知ESP32开发板和OV7670摄像头模块,实现了一个功能完整的简易照相机系统。系统采用QQVGA(160×120)分辨率,RGB565色彩格式,在保证图像质量的同时控制数据传输量,确保ESP32能够稳定处理。通过优化的WebSocket传输协议,实现了在网页端实时显示摄像头画面和拍照功能。

(2)项目难点及解决方案

       问题描述1:ESP32 WROOM内存有限

解决方案:采用预分配内存策略,分配好图像传输缓冲区,避免运行时动态内存分配

       问题描述2:WebSocket实时传输视频流需要高效的数据压缩

解决方案:将每帧图像分成多行传输,减少单次数据传输量

一、硬件接线部分

1.1 硬件清单

组件名称规格型号数量备注
主控板零知ESP32 WROOM1核心处理单元
摄像头模块OV7670130万像素,支持RGB565输出
连接线杜邦线若干用于各模块间连接
电源USB数据线15V供电
电阻10kΩ2用于I2C上拉

1.2 接线方案

        模块使用3.3V供电,OV7670摄像头与ESP32的连接按照以下的引脚定义进行:

const camera_config_t cam_conf = {
  .D0 = 36,    // 数据位0
  .D1 = 39,    // 数据位1
  .D2 = 34,    // 数据位2
  .D3 = 35,    // 数据位3
  .D4 = 32,    // 数据位4
  .D5 = 33,    // 数据位5
  .D6 = 25,    // 数据位6
  .D7 = 26,    // 数据位7
  .XCLK = 15,  // 时钟信号
  .PCLK = 14,  // 像素时钟
  .VSYNC = 13, // 垂直同步信号
  .xclk_freq_hz = 10000000,  // 10MHz时钟
  .ledc_timer = LEDC_TIMER_0,
  .ledc_channel = LEDC_CHANNEL_0  
};

        注意:SCCB通信还需要连接I2C总线,SDA接ESP32的GPIO21、SCL接GPIO22。RET复位接3.3V,PWDN接GND

1.3 具体接线图

        ps:模块上的LDO可以将3.3V转换为2.8V和1.8V供摄像头使用

1.4 连接实物图

二、代码解释部分

2.1 核心代码结构

        ①摄像头初始化模块:配置OV7670寄存器和工作参数

        ②网络连接模块:处理WiFi连接和Web服务器启动、

        ③WebSocket传输模块:实现实时视频流数据传输

        ④网页服务模块:提供用户交互界面

        ⑤图像处理模块:处理图像数据的采集和转换

2.2 摄像头初始化

// 摄像头配置结构体
const camera_config_t cam_conf = {
  .D0 = 36,    // 数据位0
  .D1 = 39,    // 数据位1
  // ... 其他引脚配置
  .xclk_freq_hz = 10000000,  // 10MHz时钟
  .ledc_timer = LEDC_TIMER_0,
  .ledc_channel = LEDC_CHANNEL_0  
};

// 摄像头初始化
esp_err_t err = cam.init(&cam_conf, CAM_RES, RGB565);
if(err != ESP_OK){
  Serial.println(F("cam.init ERROR"));
  while(1);  // 初始化失败时停止程序
}

        cam_conf:摄像头配置结构体,包含引脚定义和时钟配置
        CAM_RES:分辨率设置,本项目使用QQVGA(160x120)
        RGB565:色彩格式,每个像素占用2字节

2.3 WebSocket图像传输

// 初始化图像传输头部
bool setImgHeader(uint16_t w, uint16_t h){
  line_h = h;
  line_size = w * 2;  // RGB565格式,每个像素2字节
  data_size = 2 + line_size * h;  // 行号(2字节) + 图像数据
  
  // 分配内存
  WScamData = (uint8_t*)malloc(data_size + 4);  // +4字节WebSocket头部
  
  // 设置WebSocket帧头部
  WScamData[0] = OP_BIN;  // 二进制数据
  WScamData[1] = 126;     // 数据长度标识
  WScamData[2] = (uint8_t)(data_size / 256);  // 数据长度高字节
  WScamData[3] = (uint8_t)(data_size % 256);  // 数据长度低字节
  return true;  
}

// 发送图像数据
void WS_sendImg(uint16_t lineNo){
  // 设置行号
  WScamData[4] = (uint8_t)(lineNo % 256);
  WScamData[5] = (uint8_t)(lineNo / 256);
  
  // 发送数据
  uint16_t len = data_size + 4;
  uint8_t *pData = WScamData;
  while(len){
    uint16_t send_size = (len > UNIT_SIZE) ? UNIT_SIZE : len;
    WSclient.write(pData, send_size);
    len -= send_size;
    pData += send_size;
  }
}

        WebSocket二进制数据传输协议
        分块传输机制,避免大数据包传输问题

2.4 图像数据采集与处理

       ① 将RGB565数据转换为适合网络传输的格式
       ② 通过WebSocket发送数据到网页端

        ③ 网页端JavaScript将数据转换为图像显示

// 主循环中的图像采集
void loop(void){
  uint16_t y, dy;
  dy = CAM_HEIGHT / CAM_DIV;  // 每次处理的行数
  
  while(1){
    for(y = 0; y < CAM_HEIGHT; y += dy){      
      // 获取dy行图像数据
      cam.getLines(y+1, &WScamData[6], dy);
      
      if(WS_on && !snapshotInProgress){
        if(WSclient){
          WS_sendImg(y);  // 发送图像数据
        }
      }
    }
    
    if(!WS_on){
      Ini_HTTP_Response();  // 处理HTTP请求
    }
  }
}

2.5 完整代码

//*************************************************************************
// OV7670 (non FIFO) Simple Web streamer for ESP32 
// Optimized version for QQVGA (160x120) resolution
// Added snapshot functionality with dropdown menu
//*************************************************************************
#include <Wire.h>
#include <SPI.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include <WiFi.h>
#include <WiFiMulti.h>
#include "hwcrypto/sha.h"
#include "base64.h"
#include <OV7670.h>

// Network configuration
IPAddress myIP = IPAddress(192, 168, 3, 78);    // Static IP address
IPAddress myGateway = IPAddress(192, 168, 3, 1);

// Camera pin configuration
const camera_config_t cam_conf = {
  .D0 = 36,
  .D1 = 39,
  .D2 = 34,
  .D3 = 35,
  .D4 = 32,
  .D5 = 33,
  .D6 = 25,
  .D7 = 26,
  .XCLK = 15,
  .PCLK = 14,
  .VSYNC = 13,
  .xclk_freq_hz = 10000000,      // XCLK 10MHz
  .ledc_timer = LEDC_TIMER_0,
  .ledc_channel = LEDC_CHANNEL_0  
};
// SSCB_SDA(SIOD) --> 21(ESP32)
// SSCB_SCL(SIOC) --> 22(ESP32)
// RESET   --> 3.3V
// PWDN    --> GND
// HREF    --> NC

// Camera resolution settings for QQVGA
#define CAM_RES     QQVGA     // Camera resolution
#define CAM_WIDTH   160       // Image width
#define CAM_HEIGHT  120       // Image height
#define CAM_DIV     1         // Number of divisions per frame

OV7670 cam;                   // Camera object
WiFiServer server(80);        // Web server on port 80
WiFiClient WSclient;          // WebSocket client
boolean WS_on = false;        // WebSocket connection flag
WiFiMulti wifiMulti;          // WiFi multi-connection manager

// HTML and JavaScript content for the web page
const char *html_head = "HTTP/1.1 200 OK\r\n"
  "Content-type:text/html\r\n"
  "Connection:close\r\n"    
  "\r\n"    // Empty line
  "<!DOCTYPE html>\n"
  "<html lang='ja'>\n"
  "<head>\n"
  "<meta charset='UTF-8'>\n"
  "<meta name='viewport' content='width=device-width'>\n"
  "<title>OV7670 实时摄像头</title>\n"
  "<style>\n"
  "body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }\n"
  ".container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }\n"
  "h1 { color: #333; text-align: center; margin-bottom: 20px; }\n"
  "#msg { font-size: 18px; color: #FF0000; text-align: center; margin: 10px 0; }\n"
  "#msgIn { font-size: 16px; color: #007BFF; text-align: center; margin: 10px 0; }\n"
  ".controls { display: flex; justify-content: center; gap: 10px; margin: 15px 0; flex-wrap: wrap; }\n"
  "button { padding: 10px 15px; font-size: 14px; border: none; border-radius: 4px; cursor: pointer; transition: background 0.3s; }\n"
  ".btn-primary { background: #007BFF; color: white; }\n"
  ".btn-primary:hover { background: #0056b3; }\n"
  ".btn-danger { background: #dc3545; color: white; }\n"
  ".btn-danger:hover { background: #c82333; }\n"
  ".btn-success { background: #28a745; color: white; }\n"
  ".btn-success:hover { background: #218838; }\n"
  ".video-container { text-align: center; margin: 15px 0; padding: 10px; background: #f8f9fa; border-radius: 4px; }\n"
  ".snapshot-management { margin: 15px 0; text-align: center; }\n"
  "select { padding: 8px; border: 1px solid #ddd; border-radius: 4px; margin-right: 10px; }\n"
  ".snapshot-display { text-align: center; margin-top: 15px; }\n"
  "#currentSnapshot { max-width: 100%; border: 2px solid #ddd; border-radius: 4px; }\n"
  "</style>\n"
  "</head>\n"
  "<body>\n"
  "<div class='container'>\n"
  "<h1>ESP32 OV7670 照相机</h1>\n";
  
const char *html_body =  
  "<div id='msg'>WebSocket 正在连接...</div>\n"
  "<div id='msgIn'>0 fps</div>\n"
  "<div class='controls'>\n"
  "<button class='btn-primary' onclick='takeSnapshot()'>点击拍照</button>\n"
  "<button class='btn-success' onclick='saveSnapshot()'>保存当前照片</button>\n"
  "</div>\n"
  "<div class='video-container'>\n"
  "<canvas id='cam_canvas' width='160' height='120'></canvas>\n"
  "</div>\n"
  "<div class='snapshot-management'>\n"
  "<select id='snapshotSelector' onchange='showSelectedSnapshot()'>\n"
  "<option value=''>-- 选择照片 --</option>\n"
  "</select>\n"
  "<button class='btn-danger' onclick='deleteSelectedSnapshot()'>删除所选照片</button>\n"
  "</div>\n"
  "<div class='snapshot-display'>\n"
  "<img id='currentSnapshot' src='' alt='选中的照片将显示在这里'>\n"
  "</div>\n"
  "<script language='javascript' type='text/javascript'>\n"
  "var wsUri = 'ws://";

// 完整的JavaScript代码作为一个字符串常量
const char *html_script =  
  "var socket = null;\n"
  "var tms;\n"    
  "var msgIn;\n"
  "var msg;\n"    
  "var ctx;\n"
  "var width;\n"
  "var height;\n"  
  "var imageData;\n"    
  "var pixels;\n"  
  "var fps = 0;\n"
  "var snapshotMode = false;\n"
  "var snapshotData = null;\n"
  "var snapshotIndex = 0;\n"
  "var snapshots = {};\n"  // 存储所有快照的对象,键为时间戳,值为DataURL
  "var currentSnapshotCanvas = null;\n"  // 当前显示的快照canvas
          
  "window.onload = function(){\n"
  " msgIn = document.getElementById('msgIn');\n"
  " msg = document.getElementById('msg');\n"
  " var c = document.getElementById('cam_canvas');\n"
  " ctx = c.getContext('2d');\n"
  " width = c.width;\n"
  " height = c.height;\n"   
  " imageData = ctx.createImageData( width, 1 );\n"
  " pixels = imageData.data;\n"   
  " setTimeout('ws_connect()', 1000);\n"
  "}\n"
    
  "function Msg(message){ msg.innerHTML = message;}\n"            
  
  "function ws_connect(){\n"  
  " tms = new Date();\n"    
  " if(socket == null){\n"
  "  socket = new WebSocket(wsUri);\n"
  "  socket.binaryType = 'arraybuffer';\n"    
  "  socket.onopen   = function(evt){ onOpen(evt) };\n"
  "  socket.onclose   = function(evt){ onClose(evt) };\n"
  "  socket.onmessage = function(evt){ onMessage(evt) };\n"
  "  socket.onerror   = function(evt){ onError(evt) };\n"
  " }\n"
  " setTimeout('fpsShow()', 1000);\n"  
  "}\n"
    
  "function onOpen(evt){ Msg('已连接');}\n"
  "function onClose(evt){ Msg('WS.Close.DisConnected ' + evt.code +':'+ evt.reason); WS_close();}\n"
  "function onError(evt){ Msg(evt.data);}\n"

  "function onMessage(evt){\n"
  " var data = evt.data;\n"
  " if( typeof data == 'string'){\n"
  "  if(data.startsWith('SNAPSHOT:')) {\n"
  "   handleSnapshotData(data.substring(9));\n"
  "  } else {\n"
  "   msgIn.innerHTML = data;\n"
  "  }\n"
  " }else if( data instanceof ArrayBuffer){\n"
  "  if(snapshotMode) {\n"
  "   handleSnapshotBinary(data);\n"
  "  } else {\n"
  "   drawLine(data);\n"
  "  }\n"
  " }else if( data instanceof Blob){\n"
  "  Msg('Blob data received');\n"     
  " }\n"
  "}\n"    

  "function WS_close(){\n"
  " socket.close();\n"
  " socket = null;\n"
  " setTimeout('ws_connect()', 1);\n"  // Try to reconnect after 1ms
  "}\n"
  
  "function fpsShow(){\n"    // Display frames per second
  " msgIn.innerHTML = String(fps)+'fps';\n"
  " fps = 0;\n"
  " setTimeout('fpsShow()', 1000);\n"
  "}\n"
  
  "function drawLine(data){\n"
  " var buf = new Uint16Array(data);\n"  
  " var lineNo = buf[0];\n"
  " for(var y = 0; y < (buf.length-1)/width; y+=1){\n"     
  "  var base = 0;\n"
  "  for(var x = 0; x < width; x += 1){\n"
  "   var c = 1 + x + y * width;\n"     
  "   pixels[base+0] = (buf[c] & 0xf800) >> 8 | (buf[c] & 0xe000) >> 13;\n"  // Red
  "   pixels[base+1] = (buf[c] & 0x07e0) >> 3 | (buf[c] & 0x0600) >> 9;\n"   // Green
  "   pixels[base+2] = (buf[c] & 0x001f) << 3 | (buf[c] & 0x001c) >> 2;\n"   // Blue
  "   pixels[base+3] = 255;\n"  // Alpha
  "   base += 4;\n"
  "  }\n"
  "  ctx.putImageData(imageData, 0, lineNo + y);\n"
  " }\n"
  " if(lineNo + y == height) fps+=1;\n"  
  "}\n"
  
  "function takeSnapshot() {\n"
  " if(socket && socket.readyState === WebSocket.OPEN) {\n"
  "  snapshotMode = true;\n"
  "  snapshotData = new Uint16Array(19200);\n"  // 160*120 = 19200
  "  snapshotIndex = 0;\n"
  "  socket.send('SNAPSHOT');\n"
  "  Msg('正在拍照...');\n"
  " }\n"
  "}\n"
  
  "function saveSnapshot() {\n"
  " if(currentSnapshotCanvas) {\n"
  "   var timestamp = new Date().toLocaleString();\n"
  "   var dataURL = currentSnapshotCanvas.toDataURL();\n"
  "   snapshots[timestamp] = dataURL;\n"
  "   \n"
  "   // 更新下拉菜单\n"
  "   var selector = document.getElementById('snapshotSelector');\n"
  "   var option = document.createElement('option');\n"
  "   option.value = timestamp;\n"
  "   option.textContent = timestamp;\n"
  "   selector.appendChild(option);\n"
  "   \n"
  "   // 自动选择新添加的快照\n"
  "   selector.value = timestamp;\n"
  "   showSelectedSnapshot();\n"
  "   \n"
  "   Msg('照片已保存: ' + timestamp);\n"
  " } else {\n"
  "   Msg('没有可保存的照片');\n"
  " }\n"
  "}\n"
  
  "function showSelectedSnapshot() {\n"
  " var selector = document.getElementById('snapshotSelector');\n"
  " var selectedValue = selector.value;\n"
  " var imgElement = document.getElementById('currentSnapshot');\n"
  " \n"
  " if(selectedValue && snapshots[selectedValue]) {\n"
  "   imgElement.src = snapshots[selectedValue];\n"
  "   imgElement.style.display = 'block';\n"
  "   Msg('Showing snapshot: ' + selectedValue);\n"
  " } else {\n"
  "   imgElement.src = '';\n"
  "   imgElement.style.display = 'none';\n"
  "   Msg('No snapshot selected');\n"
  " }\n"
  "}\n"
  
  "function deleteSelectedSnapshot() {\n"
  " var selector = document.getElementById('snapshotSelector');\n"
  " var selectedValue = selector.value;\n"
  " \n"
  " if(selectedValue && snapshots[selectedValue]) {\n"
  "   // 从对象中删除\n"
  "   delete snapshots[selectedValue];\n"
  "   \n"
  "   // 从下拉菜单中删除\n"
  "   for(var i = 0; i < selector.options.length; i++) {\n"
  "     if(selector.options[i].value === selectedValue) {\n"
  "       selector.remove(i);\n"
  "       break;\n"
  "     }\n"
  "   }\n"
  "   \n"
  "   // 清空显示\n"
  "   var imgElement = document.getElementById('currentSnapshot');\n"
  "   imgElement.src = '';\n"
  "   imgElement.style.display = 'none';\n"
  "   \n"
  "   // 重置选择器\n"
  "   selector.value = '';\n"
  "   \n"
  "   Msg('选中照片已删除: ' + selectedValue);\n"
  " } else {\n"
  "   Msg('请选择要删除的照片');\n"
  " }\n"
  "}\n"
  
  "function handleSnapshotBinary(data) {\n"
  " var buf = new Uint16Array(data);\n"
  " var lineNo = buf[0];\n"
  " \n"
  " for(var i = 1; i < buf.length; i++) {\n"
  "  if(snapshotIndex < snapshotData.length) {\n"
  "   snapshotData[snapshotIndex++] = buf[i];\n"
  "  }\n"
  " }\n"
  " \n"
  " if(snapshotIndex >= snapshotData.length) {\n"
  "  completeSnapshot();\n"
  " }\n"
  "}\n"
  
  "function completeSnapshot() {\n"
  " snapshotMode = false;\n"
  " \n"
  " // Create a new canvas for the snapshot\n"
  " var canvas = document.createElement('canvas');\n"
  " canvas.width = width;\n"
  " canvas.height = height;\n"
  " \n"
  " var snapCtx = canvas.getContext('2d');\n"
  " var snapImageData = snapCtx.createImageData(width, height);\n"
  " var snapPixels = snapImageData.data;\n"
  " \n"
  " // Convert RGB565 to RGBA\n"
  " for(var i = 0; i < snapshotData.length; i++) {\n"
  "  var base = i * 4;\n"
  "  snapPixels[base+0] = (snapshotData[i] & 0xf800) >> 8 | (snapshotData[i] & 0xe000) >> 13;  // Red\n"
  "  snapPixels[base+1] = (snapshotData[i] & 0x07e0) >> 3 | (snapshotData[i] & 0x0600) >> 9;   // Green\n"
  "  snapPixels[base+2] = (snapshotData[i] & 0x001f) << 3 | (snapshotData[i] & 0x001c) >> 2;   // Blue\n"
  "  snapPixels[base+3] = 255;  // Alpha\n"
  " }\n"
  " \n"
  " snapCtx.putImageData(snapImageData, 0, 0);\n"
  " \n"
  " // 存储当前快照的canvas引用\n"
  " currentSnapshotCanvas = canvas;\n"
  " \n"
  " // 显示当前快照\n"
  " var imgElement = document.getElementById('currentSnapshot');\n"
  " imgElement.src = canvas.toDataURL();\n"
  " imgElement.style.display = 'block';\n"
  " \n"
  " Msg('拍照完成!点击 \"保存当前照片\" 进行保存');\n"
  "}\n"
  
  "function handleSnapshotData(data) {\n"
  " // Handle text-based snapshot data (if implemented)\n"
  " console.log('Snapshot data: ' + data);\n"
  "}\n"
  "</script>\n"    
  "</div>\n"
  "</body>\n"
  "</html>\n";

// WebSocket protocol constants
#define WS_FIN   0x80
#define OP_TEXT  0x81
#define OP_BIN   0x82
#define OP_CLOSE 0x88
#define OP_PING  0x89
#define OP_PONG  0x8A
#define WS_MASK  0x80

// Global variables for image data transmission
uint8_t *WScamData = nullptr;
uint16_t data_size = 0;
uint16_t line_size = 0;
uint16_t line_h = 0;

// Snapshot variables
bool snapshotRequested = false;
bool snapshotInProgress = false;
uint16_t snapshotBuffer[CAM_WIDTH * CAM_HEIGHT];  // Buffer for snapshot data
uint16_t snapshotIndex = 0;

// WiFi connection function
bool wifi_connect(){
  wifiMulti.addAP("zaixinjian", "2020zaixinjian");  // WiFi credentials
  // Add more APs as needed
  
  Serial.println(F("Connecting Wifi..."));
  if(wifiMulti.run() == WL_CONNECTED) {
    WiFi.config(myIP, myGateway, IPAddress(255,255,255,0));  // Set static IP
      
    Serial.println(F("--- WiFi connected ---"));
    Serial.print(F("SSID: "));
    Serial.println(WiFi.SSID());
    Serial.print(F("IP Address: "));
    Serial.println(WiFi.localIP());
    Serial.print(F("signal strength (RSSI): "));
    Serial.print(WiFi.RSSI());  // Signal strength
    Serial.println(F("dBm"));      
    return true;
  }
  else return false;
}

// Send HTML page to client
void printHTML(WiFiClient &client){
  Serial.println("sendHTML ...");
  client.print(html_head);
  Serial.println("head done");

  client.print(html_body);
  client.print(WiFi.localIP());
  client.println(F("/';"));
  Serial.println("body done");
  client.println(html_script);
  Serial.println("sendHTML Done");    
}

// Generate WebSocket accept key
String Hash_Key(String h_req_key){
  unsigned char hash[20];
  String str = h_req_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";  
  esp_sha(SHA1, (unsigned char*)str.c_str(), str.length(), hash);
  str = base64::encode(hash, 20);
  return str;  
}

// WebSocket handshake procedure
void WS_handshake(WiFiClient &client){
  String req;
  String hash_req_key;
  
  Serial.println(F("-----from Browser HTTP WebSocket Request---------"));
  // Read browser request until empty line
  do{
    req = client.readStringUntil('\n');  // Read until newline
    Serial.println(req);
    if(req.indexOf("Sec-WebSocket-Key") >= 0){
      hash_req_key = req.substring(req.indexOf(':')+2, req.indexOf('\r'));
      Serial.println();
      Serial.print(F("hash_req_key ="));
      Serial.println(hash_req_key);
    }        
  }while(req.indexOf("\r") != 0);
  
  req = "";
  delay(10);

  // Send WebSocket handshake response
  Serial.println(F("---send WS HTML..."));
  String str = "HTTP/1.1 101 Switching Protocols\r\n";
  str += "Upgrade: websocket\r\n";
  str += "Connection: Upgrade\r\n";
  str += "Sec-WebSocket-Accept: ";
  str += Hash_Key(hash_req_key);  // Hash -> BASE64 encoded key
  str += "\r\n\r\n";              // Empty line is required
  Serial.println(str);
  client.print(str);              // Send to client
  str = "";
  WSclient = client;
}

// Handle WebSocket messages
void handleWebSocketMessage(uint8_t *data, size_t len) {
  if (len >= 7 && strncmp((char*)data, "SNAPSHOT", 7) == 0) {
    Serial.println("Snapshot requested");
    snapshotRequested = true;
  }
}

// Process WebSocket data
void processWebSocketData(uint8_t *data, size_t len) {
  if (len < 2) return;
  
  uint8_t opcode = data[0] & 0x0F;
  bool isMasked = (data[1] & 0x80) != 0;
  uint64_t payloadLength = data[1] & 0x7F;
  uint8_t maskIndex = 2;
  
  if (payloadLength == 126) {
    if (len < 4) return;
    payloadLength = (data[2] << 8) | data[3];
    maskIndex = 4;
  } else if (payloadLength == 127) {
    if (len < 10) return;
    // For 64-bit length, we don't handle extremely large payloads
    return;
  }
  
  if