Chapter 100: Custom Network Protocol Development
Chapter Objectives
After completing this chapter, you will be able to:
- Understand the rationale and trade-offs for developing custom network protocols.
- Identify key design considerations for creating an application-specific protocol.
- Choose appropriate transport layers (TCP or UDP) based on protocol requirements.
- Design message framing and data serialization techniques for custom protocols.
- Implement basic custom protocols on the ESP32 using sockets.
- Develop both client and server roles for custom protocols on the ESP32.
- Consider error handling, security, and extensibility in protocol design.
- Apply these concepts to create efficient and tailored network communication for ESP32 applications.
Introduction
Throughout this volume, we’ve explored a multitude of standard networking protocols like HTTP, MQTT, WebSockets, and SNTP. These protocols are powerful, well-defined, and suitable for a wide array of applications. However, there are scenarios where the overhead of standard protocols might be undesirable, or their features might not perfectly align with the specific needs of a highly optimized or unique embedded application. In such cases, developing a custom network protocol can offer significant advantages in terms of efficiency, simplicity, or tailored functionality.
This chapter will guide you through the process of designing and implementing your own custom network protocols on the ESP32. We’ll cover the fundamental principles, from choosing the right transport layer (TCP or UDP) to defining message structures, serializing data, and handling communication logic. While creating a custom protocol requires careful thought and planning, it can unlock performance and resource utilization benefits crucial for constrained embedded systems. This chapter will empower you to build lean, efficient, and perfectly tailored communication solutions for your ESP32 projects.
Theory
Why Custom Protocols?
While standard protocols offer interoperability and rich feature sets, custom protocols can be advantageous in specific situations:
graph TD A["Start: Need Network Communication?"] -->|"Yes"| B{"Standard Protocol Adequate? <br>(HTTP, MQTT, WebSocket, etc.)"}; A -->|"No"| Z["No Network Protocol Needed"]; B -->|"Yes"| C["Use Standard Protocol"]; B -->|"No"| D{"Specific Reasons for Custom?"}; D -->|"No"| C; D -->|"Yes"| E{"Efficiency Critical? <br> (Low Bandwidth/Power, Min Overhead)"}; E -->|"Yes"| F["Consider Custom Protocol"]; E -->|"No"| G{"Simplicity/Tailored Functionality Needed?"}; G -->|"Yes"| F; G -->|"No"| H{"Unique Communication Pattern?"}; H -->|"Yes"| F; H -->|"No"| I{"Proprietary/Closed System?"}; I -->|"Yes"| F; I -->|"No"| J{"Extreme Resource Constraints?"}; J -->|"Yes"| F; J -->|"No"| K["Re-evaluate Standard Protocols with Customizations/Profiles"]; K --> C; F --> L{"Trade-offs Acceptable? <br> (Interoperability, Dev Effort, Security Risks)"}; L -->|"Yes"| M["Develop Custom Protocol"]; L -->|"No"| K; C --> X["End: Solution Found"]; M --> X; Z --> X; classDef primary fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6; classDef success fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46; classDef decision fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E; classDef process fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF; classDef check fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B; class A,M primary; class B,D,E,G,H,I,J,L decision; class F,K process; class C,X,Z success;
- Efficiency:
- Reduced Overhead: Standard protocols often include headers and features not needed by every application. A custom protocol can strip these down to the bare essentials, minimizing packet size and processing requirements. This is particularly important for battery-powered devices or networks with limited bandwidth.
- Optimized Data Representation: Custom binary formats can be more compact than text-based formats like JSON or XML used in some standard protocols.
- Simplicity:
- Tailored Functionality: You implement only the features you need, leading to simpler client and server logic.
- Easier Debugging (for simple protocols): A well-defined, minimal custom protocol can sometimes be easier to debug than complex interactions within a standard protocol stack.
- Specific Requirements:
- Unique Communication Patterns: Your application might have communication patterns not well-suited to existing request-response or publish-subscribe models.
- Proprietary Systems: For closed systems where interoperability with external standard clients/servers is not a goal.
- Extreme Resource Constraints: On very low-power or memory-constrained devices, even a lightweight standard protocol might be too heavy.
Aspect | Advantages of Custom Protocols | Disadvantages/Trade-offs |
---|---|---|
Efficiency |
|
Can be less efficient if poorly designed (e.g., inefficient serialization, chatty interactions). |
Simplicity |
|
Complexity can grow quickly if requirements expand; debugging can be hard without standard tools. |
Specific Requirements |
|
Limited to the specific application; not suitable for general-purpose communication. |
Interoperability | Not a primary goal; designed for a specific system. | Lack of Standard Interoperability: Cannot be understood by standard tools, browsers, or other off-the-shelf clients/servers. |
Development Effort | Can be quicker for very simple needs. |
|
Security | Can be designed with specific security needs in mind from the ground up (though this is hard). |
|
Maintenance & Evolution | Can be straightforward if the protocol is simple and well-documented. | Can become difficult to maintain and extend if not designed with versioning and extensibility in mind. |
Trade-offs:
However, developing custom protocols comes with trade-offs:
- Lack of Interoperability: Your custom protocol won’t be understood by standard tools or clients.
- Development Effort: You are responsible for defining, implementing, and debugging all aspects of the protocol.
- Reinventing the Wheel: You might end up re-implementing features already robustly provided by standard protocols (e.g., reliability, security).
- Security Risks: Designing secure custom protocols is challenging. Standard protocols often have well-vetted security mechanisms (like TLS).
Therefore, the decision to create a custom protocol should be made after carefully evaluating whether existing standard protocols can meet the requirements.
Design Considerations
Designing a custom network protocol involves several key decisions:
- Transport Layer Choice (TCP vs. UDP):
- UDP (User Datagram Protocol):
- Connectionless, message-oriented.
- Low overhead, fast.
- Unreliable: Packets can be lost, duplicated, or arrive out of order. No built-in congestion control.
- Suitable for: Real-time data where occasional loss is acceptable (e.g., streaming sensor data for non-critical display), simple request-response where the application can handle retries, service discovery via broadcast/multicast.
- TCP (Transmission Control Protocol):
- Connection-oriented, stream-based.
- Reliable: Guarantees ordered delivery of data without loss or duplication (through acknowledgments and retransmissions).
- Flow and congestion control.
- Higher overhead than UDP due to connection setup and state management.
- Suitable for: Applications requiring reliable data transfer (e.g., command and control, firmware updates, critical data logging).
- UDP (User Datagram Protocol):
Feature | UDP (User Datagram Protocol) | TCP (Transmission Control Protocol) |
---|---|---|
Connection Type | Connectionless | Connection-oriented |
Data Delivery | Message-oriented (datagrams) | Stream-oriented |
Reliability | Unreliable: Packets can be lost, duplicated, or arrive out of order. No built-in retransmission. | Reliable: Guarantees ordered delivery without loss or duplication via acknowledgments and retransmissions. |
Overhead | Low (smaller header, no connection setup/teardown state) | Higher (larger header, connection setup handshake, state management) |
Speed | Generally faster due to lower overhead and no connection management. | Can be slower due to reliability mechanisms and connection management. |
Flow Control | No built-in flow control. Application must handle or tolerate. | Built-in flow control to prevent sender from overwhelming receiver. |
Congestion Control | No built-in congestion control. Can contribute to network congestion if not managed by application. | Built-in congestion control to adapt to network conditions. |
Message Boundaries | Preserves message boundaries (one send() typically maps to one recv() for the datagram). |
Does not preserve message boundaries (data is a continuous stream). Application needs message framing. |
Use Cases for Custom Protocols |
|
|
ESP32 Implementation Notes | Simpler socket setup. Need to handle potential packet loss or out-of-order arrival in application logic if reliability is needed. | More complex socket setup (listen, accept for servers). Requires message framing logic. LwIP handles reliability. |
- Message Framing (especially for TCP):
- TCP provides a reliable byte stream, not distinct messages. The receiver needs a way to determine where one message ends and the next begins.
- Common techniques:
- Delimiter-based: A special character or sequence of characters (e.g., newline
\n
, CRLF\r\n
) marks the end of a message. Simple but requires scanning the data and escaping delimiters if they appear in the payload. - Length-prefixing: Each message is preceded by a fixed-size field indicating the length of the upcoming message payload. The receiver reads the length, then reads that many bytes for the payload. More robust than delimiters.
- Fixed-size messages: All messages have a predefined, constant size. Simplest but least flexible.
- Delimiter-based: A special character or sequence of characters (e.g., newline
- UDP, being message-oriented, inherently frames data at the packet level. A single
send()
call on a UDP socket typically corresponds to a singlerecv()
on the receiver, yielding one datagram. However, you still need to define the structure within that datagram.
graph TD subgraph TCP Stream Input direction LR S["Continuous Byte Stream from TCP Socket"] end S --> P1["Framing Logic Needed"]; subgraph FramingTechniques["Framing Techniques (Applied by Receiver)"] direction TB subgraph DelimiterBased["Delimiter-Based"] direction LR DB1["Read Chunks"] --> DB2{"Delimiter Found? <br> (e.g., \<i>\\n\</i>)"}; DB2 -- "No" --> DB1; DB2 -- "Yes" --> DB3["Extract Message"]; end subgraph LengthPrefixed["Length-Prefixing"] direction LR LP1["Read Fixed-Size Length Field (L)"] --> LP2["Read L Bytes for Payload"]; LP2 --> LP3["Extract Message"]; end subgraph FixedSize["Fixed-Size Messages"] direction LR FS1["Read Fixed N Bytes"] --> FS2["Extract Message"]; end end P1 --> DelimiterBased; P1 --> LengthPrefixed; P1 --> FixedSize; DB3 --> EM["Processed Message"]; LP3 --> EM; FS2 --> EM; classDef primary fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6; classDef success fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46; classDef decision fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E; classDef process fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF; classDef check fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B; class S,P1,EM primary; class DB2 decision; class DB1,DB3,LP1,LP2,LP3,FS1,FS2 process; class FramingTechniques,DelimiterBased,LengthPrefixed,FixedSize process; style DelimiterBased fill:#f9f9f9,stroke:#333,stroke-width:1px style LengthPrefixed fill:#f9f9f9,stroke:#333,stroke-width:1px style FixedSize fill:#f9f9f9,stroke:#333,stroke-width:1px style FramingTechniques fill:#efefef,stroke:#333,stroke-width:2px style S fill:#FFF3E0,stroke:#FFB74D,stroke-width:2px,color:#E65100 style EM fill:#E8F5E9,stroke:#66BB6A,stroke-width:2px,color:#1B5E20
- Data Serialization:
- How data is converted into a byte stream for transmission and parsed back at the receiver.
- Text-based:
- Examples: JSON, XML, CSV, custom key-value pairs.
- Pros: Human-readable, easy to debug, widely supported by parsing libraries.
- Cons: Verbose (high overhead), slower to parse compared to binary.
- Binary (Custom):
- Define a fixed structure (like a C
struct
) or a sequence of well-defined fields (e.g., first byte is command, next two bytes are length, then payload). - Pros: Compact, efficient to parse, minimal overhead.
- Cons: Not human-readable, requires careful handling of byte order (endianness) if communicating between different architectures, less flexible if the structure changes.
- Define a fixed structure (like a C
- Binary (Standardized):
- Examples: Protocol Buffers (Protobuf), MessagePack, CBOR.
- Pros: Offer a balance of efficiency, type safety, and schema evolution capabilities. Often more compact than JSON but with more structure than raw custom binary.
- Cons: May require specific libraries and a schema definition step. For this chapter, we’ll focus on simpler custom binary/text.
Serialization Type | Description & Examples | Pros | Cons | ESP32 Considerations |
---|---|---|---|---|
Text-based (Custom) | Data represented as human-readable strings.
Examples: – Key-value pairs: temp=25.3;humid=45.1
– CSV: 25.3,45.1,1012.5
– Custom commands: LED_ON
|
|
|
Good for simple command/response. Parsing can consume CPU and memory. Standard C string functions are available. |
Text-based (Standardized) | Using standard text formats.
Examples: – JSON: {"temp": 25.3, "humid": 45.1}
– XML: <data><temp>25.3</temp>...</data>
|
|
|
ESP-IDF has JSON parsing libraries (e.g., cJSON, esp-json). Consider resource impact for constrained devices. XML is generally too heavy. |
Binary (Custom) | Data represented directly in binary form, often mapping to C structs or a defined sequence of typed fields.
Example: [cmd_id (1B)] [len (2B)] [payload (var)]
|
|
|
Excellent for performance-critical and low-bandwidth applications. Use __attribute__((packed)) for structs. Be mindful of endianness if communicating with non-ESP32 devices. |
Binary (Standardized) | Using standard binary serialization formats.
Examples: – Protocol Buffers (Protobuf) – MessagePack – CBOR (Concise Binary Object Representation) |
|
|
Libraries for these might be available for ESP-IDF or can be ported. Good balance for structured, efficient data if custom binary is too rigid or text is too slow/large. Check library resource usage. |
- Command Structure / Message Types:
- Define different types of messages your protocol will support (e.g.,
GET_SENSOR_DATA
,SET_RELAY_STATE
,SENSOR_DATA_RESPONSE
,ACK
,ERROR
). - Assign unique identifiers (opcodes) to each message type, often as the first byte(s) of a message.
- Specify the parameters or payload associated with each message type.
- Define different types of messages your protocol will support (e.g.,
- Error Handling and Reporting:
- How will the protocol report errors (e.g., invalid command, failed operation, bad parameters)?
- Define specific error message types or error codes.
- Consider if acknowledgments (
ACK
/NACK
) are needed for certain operations.
- Security Considerations:
- Custom protocols are often unencrypted by default. If confidentiality or integrity is required:
- Consider layering your custom protocol over TLS (if using TCP). This requires managing certificates and the TLS handshake.
- Implement application-level encryption/authentication (more complex and error-prone).
- Think about authentication: How does the server know the client is legitimate, and vice-versa?
- Be wary of denial-of-service vulnerabilities (e.g., a client sending huge messages).
- Custom protocols are often unencrypted by default. If confidentiality or integrity is required:
Security Goal | Consideration / Threat | Potential Mitigation Strategies for Custom Protocols | ESP32 Notes |
---|---|---|---|
Confidentiality (Prevent Eavesdropping) |
Data transmitted in plaintext can be intercepted and read by unauthorized parties. |
|
ESP32 has hardware acceleration for AES. mbedTLS library can be used for TLS or standalone crypto operations. Managing keys securely on the device is critical. |
Integrity (Prevent Data Tampering) |
Data could be modified in transit without detection. |
|
Hardware SHA acceleration available. mbedTLS provides MAC functions. |
Authentication (Verify Identity) |
|
|
TLS client/server certificate handling is supported. Secure storage of PSKs or tokens (e.g., in NVS with encryption) is important. |
Availability (Prevent Denial of Service – DoS) |
Malicious actors might try to overwhelm the device or exploit protocol weaknesses to make it unresponsive. |
|
Implement robust parsing. Use FreeRTOS features to manage tasks and resources. Be cautious with dynamic memory allocation in network handlers. |
Replay Attacks | An attacker re-sends valid, previously captured messages to cause unintended actions. |
|
SNTP can be used for time synchronization. Managing nonces requires state on the server. |
Lack of Scrutiny | Custom protocols don’t benefit from the widespread review and testing that standard protocols undergo. |
|
Focus on well-understood cryptographic primitives if implementing application-level security. |
- Scalability and Extensibility:
- How will the protocol evolve if new features or message types are needed?
- Consider versioning for your protocol from the start.
- Design message formats that can accommodate future additions without breaking existing implementations (e.g., by using Type-Length-Value (TLV) encoding for parameters or leaving reserved fields).
Implementing on ESP32
- Sockets API: You’ll use the LwIP sockets API (Chapter 85-92) for sending and receiving data over TCP or UDP.
- Task Management (FreeRTOS):
- For a server handling multiple clients, each client connection might be managed in a separate FreeRTOS task, or a single task might use
select()
to handle multiple sockets. - Client logic might run in its own task or as part of a larger application task.
- For a server handling multiple clients, each client connection might be managed in a separate FreeRTOS task, or a single task might use
- Data Buffering: Manage send and receive buffers carefully.
- Parsing and Serialization Logic: Implement functions to construct outgoing messages and parse incoming messages according to your protocol definition.
Practical Examples
These examples assume you have Wi-Fi connectivity established on your ESP32 as covered in previous chapters.
Example 1: Simple UDP-based Sensor Data Protocol
Protocol Definition:
- Purpose: ESP32 client sends simulated sensor data to a server.
- Transport: UDP.
- Message Format (Binary):
Device ID
(1 byte): Identifier for the ESP32 device.Sensor Type
(1 byte): e.g., 0x01 for Temperature, 0x02 for Humidity.Sensor Value
(4 bytes): Floating-point value.- Timestamp (4 bytes): Seconds since epoch (optional, can be added by server too).Total message size: 1 + 1 + 4 + 4 = 10 bytes.
Field | Size (Bytes) | Data Type | Description | Example Value |
---|---|---|---|---|
Device ID | 1 | uint8_t |
Identifier for the ESP32 device. | 0xA1 |
Sensor Type | 1 | uint8_t |
Type of sensor reading. | 0x01 (Temp), 0x02 (Humid) |
Sensor Value | 4 | float |
The actual sensor reading. | 25.75 |
Timestamp | 4 | uint32_t |
Seconds since epoch (Unix timestamp). | 1678886400 |
Total Size | 10 | N/A | Total size of one packet. | N/A |
1. ESP32 Client (main.c
):
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_netif.h"
#include "lwip/err.h"
#include "lwip/sockets.h"
#include "lwip/sys.h"
#include <lwip/netdb.h>
#include <time.h> // For time_t
#define WIFI_SSID "YOUR_WIFI_SSID"
#define WIFI_PASS "YOUR_WIFI_PASSWORD"
#define SERVER_IP "SERVER_IP_ADDRESS" // Replace with your UDP server's IP
#define SERVER_PORT 12345
static const char *TAG = "UDP_CUSTOM_PROTO";
static SemaphoreHandle_t wifi_connected_sem;
// Our custom packet structure
typedef struct {
uint8_t device_id;
uint8_t sensor_type;
float sensor_value;
uint32_t timestamp; // Seconds since epoch
} __attribute__((packed)) sensor_packet_t; // packed to ensure no padding
static void udp_client_task(void *pvParameters) {
char host_ip[] = SERVER_IP;
int addr_family = AF_INET;
int ip_protocol = IPPROTO_IP;
struct sockaddr_in dest_addr;
dest_addr.sin_addr.s_addr = inet_addr(host_ip);
dest_addr.sin_family = AF_INET;
dest_addr.sin_port = htons(SERVER_PORT);
int sock = socket(addr_family, SOCK_DGRAM, ip_protocol);
if (sock < 0) {
ESP_LOGE(TAG, "Unable to create socket: errno %d (%s)", errno, strerror(errno));
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "Socket created, sending data to %s:%d", host_ip, SERVER_PORT);
sensor_packet_t packet;
packet.device_id = 0xA1; // Example device ID
while (1) {
// Simulate temperature reading
packet.sensor_type = 0x01; // Temperature
packet.sensor_value = 20.0f + (float)(esp_random() % 100) / 10.0f; // Random temp 20.0 - 29.9
packet.timestamp = (uint32_t)time(NULL); // Get current time as timestamp
ESP_LOGI(TAG, "Sending Temp: %.2f, TS: %u", packet.sensor_value, packet.timestamp);
int err = sendto(sock, &packet, sizeof(sensor_packet_t), 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
if (err < 0) {
ESP_LOGE(TAG, "Error occurred during sending: errno %d (%s)", errno, strerror(errno));
}
vTaskDelay(pdMS_TO_TICKS(5000)); // Send every 5 seconds
// Simulate humidity reading
packet.sensor_type = 0x02; // Humidity
packet.sensor_value = 40.0f + (float)(esp_random() % 300) / 10.0f; // Random humidity 40.0 - 69.9
packet.timestamp = (uint32_t)time(NULL);
ESP_LOGI(TAG, "Sending Humid: %.2f, TS: %u", packet.sensor_value, packet.timestamp);
err = sendto(sock, &packet, sizeof(sensor_packet_t), 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
if (err < 0) {
ESP_LOGE(TAG, "Error occurred during sending: errno %d (%s)", errno, strerror(errno));
}
vTaskDelay(pdMS_TO_TICKS(5000));
}
ESP_LOGE(TAG, "Shutting down socket and exiting task...");
shutdown(sock, 0);
close(sock);
vTaskDelete(NULL);
}
// Wi-Fi Event Handler & Init (reuse from previous chapters, e.g., Chapter 99)
static void wifi_event_handler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) {
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
xSemaphoreGive(wifi_connected_sem);
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
esp_wifi_connect();
}
}
void wifi_init_sta(void) {
wifi_connected_sem = xSemaphoreCreateBinary();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL));
wifi_config_t wifi_config = { .sta = { .ssid = WIFI_SSID, .password = WIFI_PASS }};
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA) );
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config) );
ESP_ERROR_CHECK(esp_wifi_start() );
}
void app_main(void) {
esp_err_t ret = nvs_flash_init();
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(ret);
wifi_init_sta();
if(xSemaphoreTake(wifi_connected_sem, portMAX_DELAY) == pdTRUE) {
ESP_LOGI(TAG, "Wi-Fi Connected. Starting UDP client task.");
// For timestamp to be meaningful, synchronize time first (see Chapter 98)
// For this example, we'll use un-synchronized time(NULL)
xTaskCreate(udp_client_task, "udp_client", 4096, NULL, 5, NULL);
}
}
CMakeLists.txt
(main component):
idf_component_register(SRCS "main.c"
INCLUDE_DIRS "."
REQUIRES esp_wifi esp_event esp_log nvs_flash esp_netif lwip)
2. Simple Python UDP Server (for testing):
Save as udp_server.py on your computer:
import socket
import struct
import datetime
SERVER_IP = "0.0.0.0" # Listen on all available interfaces
SERVER_PORT = 12345
BUFFER_SIZE = 1024
# Define the same structure format string for unpacking
# B = unsigned char (1 byte), f = float (4 bytes), I = unsigned int (4 bytes)
# Use '<' for little-endian, as ESP32 is little-endian
PACKET_FORMAT = "<BBfI" # DeviceID, SensorType, Value, Timestamp
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((SERVER_IP, SERVER_PORT))
print(f"UDP server listening on {SERVER_IP}:{SERVER_PORT}")
try:
while True:
data, addr = sock.recvfrom(BUFFER_SIZE)
print(f"\nReceived {len(data)} bytes from {addr}")
if len(data) == struct.calcsize(PACKET_FORMAT):
try:
device_id, sensor_type, sensor_value, timestamp = struct.unpack(PACKET_FORMAT, data)
sensor_type_str = "Temperature" if sensor_type == 0x01 else "Humidity" if sensor_type == 0x02 else "Unknown"
# Convert Unix timestamp to human-readable time
dt_object = datetime.datetime.fromtimestamp(timestamp)
print(f" Device ID : 0x{device_id:02X}")
print(f" Sensor Type: {sensor_type_str} (0x{sensor_type:02X})")
print(f" Value : {sensor_value:.2f}")
print(f" Timestamp : {timestamp} ({dt_object.strftime('%Y-%m-%d %H:%M:%S')})")
except struct.error as e:
print(f" Error unpacking data: {e}")
print(f" Raw data (hex): {data.hex()}")
else:
print(f" Received packet of unexpected size: {len(data)} bytes")
print(f" Raw data (hex): {data.hex()}")
except KeyboardInterrupt:
print("\nServer shutting down.")
finally:
sock.close()
if __name__ == "__main__":
main()
sequenceDiagram actor Client as ESP32 Client participant Server as UDP Server (Python) Client->>Client: "Prepare Sensor Packet <br> (DeviceID, Type, Value, Timestamp)" Client->>Server: "Send UDP Packet (Sensor Data)" note right of Client: "Example: DeviceID=0xA1, <br>Type=0x01 (Temp), <br>Value=25.5, TS=..." Server->>Server: "Receive UDP Packet" Server->>Server: "Unpack/Parse Sensor Data <br> (struct.unpack)" Server->>Server: "Log/Process Data" note left of Server: "Server prints decoded data <br>to console."
3. Build, Flash, and Observe:
- Replace Wi-Fi credentials in
main.c
. - Replace
SERVER_IP_ADDRESS
inmain.c
with the IP address of the computer where you’ll runudp_server.py
. - Run
python udp_server.py
on your computer. - Build and flash the ESP32 project.
- Observe the ESP32 logs and the Python server output. The server should print the decoded sensor data.
Tip: The
__attribute__((packed))
in the C struct definition is important to prevent the C compiler from adding padding bytes, ensuring the struct’s memory layout matches the intended binary protocol. Always verify endianness if communicating between different architectures. ESP32 is little-endian.
Example 2: TCP-based Command/Response Protocol (ESP32 as Server)
Protocol Definition:
- Purpose: Client sends commands to ESP32 server, server responds.
- Transport: TCP.
- Message Framing: Newline (
\n
) terminated text messages. - Commands from Client:
GET_STATUS\n
LED_ON\n
LED_OFF\n
- Responses from Server (ESP32):
STATUS:OK,TEMP:25.3,LED:ON\n
ACK:LED_ON\n
ACK:LED_OFF\n
ERR:UNKNOWN_CMD\n
ERR:CMD_FAILED\n
1. ESP32 Server (main.c
):
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_netif.h"
#include "lwip/err.h"
#include "lwip/sockets.h"
#include "lwip/sys.h"
#include <lwip/netdb.h>
#define WIFI_SSID "YOUR_WIFI_SSID_AP" // Or connect ESP32 to existing network
#define WIFI_PASS "YOUR_WIFI_PASSWORD_AP"
#define SERVER_PORT 8888
#define MAX_CLIENTS 1 // For simplicity, handle one client at a time in this example
#define RCV_BUF_SIZE 64
static const char *TAG = "TCP_CUSTOM_PROTO_SERVER";
static bool led_state = false; // false = OFF, true = ON
// Simulate getting temperature
float get_temperature_mock() {
return 20.0f + (float)(esp_random() % 150) / 10.0f; // 20.0 - 34.9
}
static void handle_tcp_client(const int sock) {
char rx_buffer[RCV_BUF_SIZE];
char tx_buffer[RCV_BUF_SIZE];
int len;
ESP_LOGI(TAG, "TCP client %d connected.", sock);
do {
len = recv(sock, rx_buffer, sizeof(rx_buffer) - 1, 0);
if (len < 0) {
ESP_LOGE(TAG, "recv failed: errno %d (%s)", errno, strerror(errno));
break;
} else if (len == 0) {
ESP_LOGI(TAG, "Connection closed by client %d", sock);
break;
} else {
rx_buffer[len] = 0; // Null-terminate whatever we received
// Remove trailing newline if present for easier string comparison
if (rx_buffer[len - 1] == '\n') {
rx_buffer[len - 1] = 0;
if (len > 1 && rx_buffer[len - 2] == '\r') { // Handle CRLF
rx_buffer[len - 2] = 0;
}
}
ESP_LOGI(TAG, "Received %d bytes from client %d: '%s'", len, sock, rx_buffer);
// Process command
if (strcmp(rx_buffer, "GET_STATUS") == 0) {
float temp = get_temperature_mock();
snprintf(tx_buffer, sizeof(tx_buffer), "STATUS:OK,TEMP:%.1f,LED:%s\n", temp, led_state ? "ON" : "OFF");
} else if (strcmp(rx_buffer, "LED_ON") == 0) {
led_state = true;
// TODO: Add actual GPIO control for an LED here
ESP_LOGI(TAG, "Turning LED ON");
snprintf(tx_buffer, sizeof(tx_buffer), "ACK:LED_ON\n");
} else if (strcmp(rx_buffer, "LED_OFF") == 0) {
led_state = false;
// TODO: Add actual GPIO control for an LED here
ESP_LOGI(TAG, "Turning LED OFF");
snprintf(tx_buffer, sizeof(tx_buffer), "ACK:LED_OFF\n");
} else {
snprintf(tx_buffer, sizeof(tx_buffer), "ERR:UNKNOWN_CMD\n");
}
int to_write = strlen(tx_buffer);
int written = send(sock, tx_buffer, to_write, 0);
if (written < 0) {
ESP_LOGE(TAG, "Error occurred during sending: errno %d (%s)", errno, strerror(errno));
break; // Exit loop on send error
} else if (written < to_write) {
ESP_LOGW(TAG, "Sent partial data: %d of %d bytes", written, to_write);
}
ESP_LOGI(TAG, "Sent: %s", tx_buffer);
}
} while (len > 0);
ESP_LOGI(TAG, "Shutting down client socket %d", sock);
shutdown(sock, 0);
close(sock);
}
static void tcp_server_task(void *pvParameters) {
char addr_str[128];
int addr_family = AF_INET;
int ip_protocol = IPPROTO_IP;
struct sockaddr_in dest_addr;
dest_addr.sin_addr.s_addr = htonl(INADDR_ANY);
dest_addr.sin_family = AF_INET;
dest_addr.sin_port = htons(SERVER_PORT);
int listen_sock = socket(addr_family, SOCK_STREAM, ip_protocol);
if (listen_sock < 0) {
ESP_LOGE(TAG, "Unable to create socket: errno %d (%s)", errno, strerror(errno));
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "Listen socket created");
int err = bind(listen_sock, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
if (err != 0) {
ESP_LOGE(TAG, "Socket unable to bind: errno %d (%s)", errno, strerror(errno));
goto CLEAN_UP;
}
ESP_LOGI(TAG, "Socket bound, port %d", SERVER_PORT);
err = listen(listen_sock, 1); // Listen with a backlog of 1
if (err != 0) {
ESP_LOGE(TAG, "Error occurred during listen: errno %d (%s)", errno, strerror(errno));
goto CLEAN_UP;
}
ESP_LOGI(TAG, "Socket listening...");
while (1) {
struct sockaddr_in source_addr; // Large enough for both IPv4 or IPv6
socklen_t addr_len = sizeof(source_addr);
int sock = accept(listen_sock, (struct sockaddr *)&source_addr, &addr_len);
if (sock < 0) {
ESP_LOGE(TAG, "Unable to accept connection: errno %d (%s)", errno, strerror(errno));
// Optionally add a small delay here if accept fails continuously
vTaskDelay(pdMS_TO_TICKS(100));
continue; // Continue listening
}
// Convert client IP address to string
if (source_addr.sin_family == PF_INET) {
inet_ntoa_r(((struct sockaddr_in *)&source_addr)->sin_addr.s_addr, addr_str, sizeof(addr_str) - 1);
}
ESP_LOGI(TAG, "Socket accepted connection from: %s, socket: %d", addr_str, sock);
handle_tcp_client(sock); // Handle one client at a time
// For multiple concurrent clients, you would typically spawn a new task here
// or use a non-blocking approach with select().
}
CLEAN_UP:
close(listen_sock);
vTaskDelete(NULL);
}
// Wi-Fi Event Handler & Init (reuse, ensure ESP32 can get an IP or is in AP mode)
// For server, AP mode is often convenient for direct client connection
static void wifi_event_handler_ap(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) {
if (event_id == WIFI_EVENT_AP_STACONNECTED) {
wifi_event_ap_staconnected_t* event = (wifi_event_ap_staconnected_t*) event_data;
ESP_LOGI(TAG, "Station "MACSTR" joined, AID=%d", MAC2STR(event->mac), event->aid);
} else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) {
wifi_event_ap_stadisconnected_t* event = (wifi_event_ap_stadisconnected_t*) event_data;
ESP_LOGI(TAG, "Station "MACSTR" left, AID=%d", MAC2STR(event->mac), event->aid);
}
}
void wifi_init_softap(void) {
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_ap(); // Create AP interface
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler_ap, NULL, NULL));
wifi_config_t wifi_config = {
.ap = {
.ssid = WIFI_SSID,
.ssid_len = strlen(WIFI_SSID),
.password = WIFI_PASS,
.max_connection = 4,
.authmode = WIFI_AUTH_WPA_WPA2_PSK
},
};
if (strlen(WIFI_PASS) == 0) {
wifi_config.ap.authmode = WIFI_AUTH_OPEN;
}
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "wifi_init_softap finished. SSID:%s password:%s", WIFI_SSID, WIFI_PASS);
esp_netif_ip_info_t ip_info;
esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"), &ip_info);
ESP_LOGI(TAG, "AP IP Address: " IPSTR, IP2STR(&ip_info.ip));
}
void app_main(void) {
esp_err_t ret = nvs_flash_init();
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(ret);
// Use SoftAP mode for the server for easier direct connection
wifi_init_softap();
// Or use wifi_init_sta() if connecting to an existing network
// Ensure you log the ESP32's IP address if in STA mode.
xTaskCreate(tcp_server_task, "tcp_server", 4096, NULL, 5, NULL);
}
CMakeLists.txt
(main component): Same as UDP example.
2. Simple Python TCP Client (for testing):
Save as tcp_client.py:
import socket
import time
# ESP32's IP address (if in STA mode) or 192.168.4.1 (if ESP32 is AP)
SERVER_IP = "192.168.4.1"
SERVER_PORT = 8888
BUFFER_SIZE = 1024
def send_command(sock, command):
print(f"Sending: {command.strip()}")
sock.sendall(command.encode('utf-8'))
response = sock.recv(BUFFER_SIZE).decode('utf-8')
print(f"Received: {response.strip()}")
return response
def main():
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((SERVER_IP, SERVER_PORT))
print(f"Connected to ESP32 server at {SERVER_IP}:{SERVER_PORT}")
send_command(s, "GET_STATUS\n")
time.sleep(1)
send_command(s, "LED_ON\n")
time.sleep(1)
send_command(s, "GET_STATUS\n")
time.sleep(1)
send_command(s, "LED_OFF\n")
time.sleep(1)
send_command(s, "GET_STATUS\n")
time.sleep(1)
send_command(s, "INVALID_CMD\n") # Test error handling
time.sleep(1)
except ConnectionRefusedError:
print(f"Connection refused. Is the ESP32 server running at {SERVER_IP}:{SERVER_PORT}?")
except Exception as e:
print(f"An error occurred: {e}")
if __name__ == "__main__":
main()
3. Build, Flash, and Observe:
- Configure Wi-Fi in
main.c
. If using AP mode, setWIFI_SSID_AP
andWIFI_PASS_AP
. - Update
SERVER_IP
intcp_client.py
to the ESP32’s IP address (default 192.168.4.1 if ESP32 is AP, or its assigned IP if in STA mode). - Build and flash the ESP32 project.
- Run
python tcp_client.py
on your computer (after connecting to the ESP32’s AP if it’s in AP mode). - Observe the client and server logs.
Variant Notes
- ESP32, ESP32-S2, ESP32-S3: Well-suited for custom protocols, both client and server roles. Dual-core variants (ESP32, S3) can better handle multiple TCP clients if server logic becomes complex. Hardware crypto can be leveraged if adding TLS to a custom TCP protocol.
- ESP32-C3, ESP32-C6: Can run custom protocols. Resource constraints (single core, RAM) might favor simpler, UDP-based, or binary protocols, especially for server applications or when WSS-like security is added. Efficiency of the custom protocol is key.
- ESP32-H2:
- If using IP over 6LoWPAN (via a Thread border router), custom IP-based protocols (UDP/TCP) are feasible but subject to the characteristics of 6LoWPAN (e.g., smaller MTU, potentially higher latency). Protocol design should be mindful of packet sizes.
- More commonly for ESP32-H2, custom protocols might be designed directly over its native 802.15.4 MAC layer or Bluetooth LE GATT custom services/characteristics if IP is not used. This involves different APIs and design principles (not covered by LwIP sockets) and would focus on very low-power, short-range communication. The principles of message definition and data serialization still apply.
General Considerations for all variants:
- Memory Usage: Complex parsing logic (e.g., for text-based protocols) or large buffers can consume significant RAM. Binary protocols are generally more memory-efficient.
- Processing Power: CPU load depends on parsing complexity and the rate of message handling.
- Flash Usage: Libraries for serialization (e.g., JSON parsers, if used) add to firmware size.
Common Mistakes & Troubleshooting Tips
Mistake / Issue | Symptom(s) | Troubleshooting / Solution |
---|---|---|
Endianness Mismatches (Binary Protocols) | Multi-byte numerical data (e.g., int , float , uint16_t ) is corrupted or nonsensical when read by the receiving system. Values appear swapped or completely wrong. |
Fix:
|
TCP Message Framing Issues |
|
Fix: Implement a clear framing mechanism:
|
Buffer Overflows | Crashes, unexpected behavior, security vulnerabilities. Occurs when incoming data exceeds the allocated buffer size during recv() or string operations. |
Fix:
|
Blocking Socket Operations Stalling Tasks | A task performing blocking calls like accept() , recv() , connect() without timeouts becomes unresponsive if no data arrives or no connection is made. Other functions in the same task don’t run. |
Fix:
|
Not Handling Partial Sends/Receives (TCP) |
|
Fix:
|
Ignoring Network Errors / Incorrect Error Checking | Socket functions (socket , bind , listen , connect , send , recv , etc.) return error codes (often -1) and set errno . Ignoring these leads to silent failures and difficult debugging. |
Fix:
|
UDP Packet Loss/Reordering Not Handled | Application assumes UDP packets will always arrive, or arrive in order. This leads to missing data or incorrect state if the application relies on sequence. |
Fix (if reliability is needed over UDP):
|
Exercises
- UDP Protocol with ACK:
- Modify the UDP sensor data protocol (Example 1).
- The server (Python script) should send back a simple acknowledgment packet (e.g.,
[DeviceID_byte][ACK_Opcode_byte]
) to the ESP32 after successfully receiving and parsing a sensor packet. - The ESP32 client should attempt to receive this ACK within a short timeout after sending data. Log if ACK is received or not. This introduces basic reliability.
- TCP Protocol Extensibility:
- Extend the TCP command/response protocol (Example 2).
- Add a new command
SET_CONFIG:PARAM_NAME=VALUE\n
(e.g.,SET_CONFIG:INTERVAL=5000\n
). - The ESP32 server should parse this, (conceptually) store the configuration, and respond with
ACK:SET_CONFIG:PARAM_NAME\n
. - Add a
GET_CONFIG:PARAM_NAME\n
command for the client to retrieve a stored configuration value.
- Length-Prefixed TCP Messaging:
- Design and implement a simple TCP client/server where messages are framed using a 2-byte length prefix (network byte order) followed by a UTF-8 string payload.
- The ESP32 can act as either client or server. The other end can be a Python script.
- Client sends “Hello ESP32”. Server receives, logs, and sends back “Hello Client”.
- Simple Service Discovery (UDP Broadcast):
- Design a protocol where an ESP32 client sends a UDP broadcast packet (e.g., containing “ESP32_DISCOVER_REQUEST”) on a specific port.
- One or more ESP32 servers on the same network listen for this broadcast.
- Upon receiving the request, each server responds directly to the client’s IP address (obtained from the received broadcast packet) with a UDP packet containing its device information (e.g., “ESP32_SERVER_RESPONSE,DeviceName,IPAddress”).
- The client collects responses for a few seconds.
Summary
- Developing custom network protocols can offer efficiency and tailored functionality when standard protocols are not a perfect fit, but involves trade-offs like reduced interoperability and increased development effort.
- Key design considerations include choosing TCP vs. UDP, defining message framing (especially for TCP), selecting data serialization methods (text vs. binary), structuring commands/message types, and planning for error handling and security.
- UDP is suitable for low-overhead, non-critical real-time data; TCP provides reliability for critical data transfer.
- Binary protocols are generally more efficient but require careful handling of endianness and are less human-readable than text-based formats like JSON or delimited strings.
- Implementation on ESP32 uses the LwIP sockets API and FreeRTOS for task management, especially for servers handling multiple clients.
- Always check return codes from socket operations and handle
errno
to diagnose issues. - Resource constraints on different ESP32 variants may influence protocol design choices, favoring simplicity and efficiency on more limited devices.
- Careful planning for extensibility and versioning can make custom protocols more maintainable in the long run.
Further Reading
- “TCP/IP Illustrated, Vol. 1: The Protocols” by W. Richard Stevens: A classic, comprehensive reference on TCP/IP protocols.
- Beej’s Guide to Network Programming: (
https://beej.us/guide/bgnet/
) An excellent practical guide to socket programming. - ESP-IDF LwIP TCP/IP Stack Documentation: (
https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32/api-reference/network/lwip.html
) - Data Serialization Formats:
- Protocol Buffers:
https://developers.google.com/protocol-buffers
- MessagePack:
https://msgpack.org/
- Protocol Buffers:
- RFCs (Request for Comments): Explore RFCs for standard protocols to understand design patterns and considerations (e.g., RFC 791 for IP, RFC 793 for TCP, RFC 768 for UDP).
This chapter marks the end of Volume 4, “Advanced Networking and Internet Protocols.” You have now journeyed from fundamental networking concepts to implementing various standard protocols and even designing your own. This knowledge forms a critical foundation for building sophisticated, connected embedded systems with the ESP32. The subsequent volumes will build upon this by exploring IoT-specific protocols, cloud connectivity, and interactions with a wider range of peripherals.