Beaconing Custom Payloads with the Hubble Device SDK on the ESP32-C6

In large-scale Internet of Things (IoT) deployments, such as asset tracking, environmental monitoring, and industrial telemetry, the edge device is only as valuable as the data it can efficiently broadcast. With the release of the ESP32-C6 DevKitC, developers have access to a highly efficient, low-power RISC-V platform equipped with native Wi-Fi 6, Zigbee, Thread, and BLE 5.3 capabilities.

Bluetooth Low Energy (BLE) advertisements provide an excellent, low-overhead mechanism for one-way communication. However, developers frequently struggle to use BLE to securely and efficiently pack custom data into a constrained payload.

In this tutorial, you’ll learn the principles behind maximizing BLE advertising efficiency using the Hubble Device SDK on the ESP32-C6 platform. We will discuss the structural constraints of BLE advertising packets, implement an embedded C solution that packs dynamic sequence numbers and system uptime into a raw byte array, and write a Python script to pull and decode this telemetry directly from the Hubble Cloud API.

BLE Advertising Constraints

A standard BLE advertising packet contains a maximum of 31 bytes of user data. Within this tiny footprint, the Bluetooth protocol requires specific formatting structures, including length indicators, AD flags, and Service UUIDs, to ensure receiving scanners can discover the device.

When working with the Hubble Network, a portion of this 31-byte space is reserved for network overhead and cryptographic integrity checks managed by the Hubble SDK. This leaves the developer with a compact, dedicated slice of 13 bytes for custom application telemetry.

To make the most of this limited space, you shouldn’t use verbose data formats like JSON or plaintext strings. For instance, sending the string "Up:50" consumes 5 bytes of text to communicate a single integer. Instead, you should use raw binary serialization, mapping integers and flags directly to specific byte offsets.

Add a Custom Payload to Your Hubble Firmware

Before you add a custom payload to your ESP32 Hubble device, make sure you have read our previous tutorials Connecting Your First ESP32 to the Hubble Network and Fetch Device Data from Hubble Cloud with ESP32-C6. This tutorial builds upon both of those workflows.

In order to add a custom payload to your Hubble beacon, you need to update your Hubble firmware using the Hubble SDK. While you can certainly write your firmware from scratch, the fastest way to get a custom payload working is to simply modify the sample beacon application.

In this example, our custom payload is a sequence number and an uptime value. The sequence number is just an incrementing integer that indicates how many packets our beacon has transmitted since it powered on, and the uptime value is the duration of our board’s uptime in seconds.

To inject custom variables, we manipulate the payload serialization layer inside the application’s primary advertising function. We use a static local variable to track sequence counts across executions and query the low-level hardware timer for system uptime.

Crucially, our code uses explicit bit-shifting rather than standard pointer casting. This guarantees network byte order (Big-Endian) across different processor architectures and completely prevents hardware alignment faults on microcontrollers that require 32-bit variables to sit on even memory boundaries.

Below is an excerpt of the modified main.c file from the sample application. It’s focused on the _get_hubble_adv() function because that’s where we defined the custom payload.

static esp_err_t _get_hubble_adv(void)
{
    esp_err_t ret;
    size_t out_len = sizeof(adv_raw_data) - _ADV_BUFFER_BASE_LEN;

    // Initialize payload variables
    static uint16_t sequence_counter = 0;
    uint32_t uptime_seconds = (uint32_t)(esp_timer_get_time() / 1000000ULL);

    // Define your custom 13-byte payload
    uint8_t payload[13] = {0}; 
    
	// Pack the Sequence Number (16-bit) into bytes 0 and 1 (Big-Endian)
    payload[0] = (uint8_t)((sequence_counter >> 8) & 0xFF);
    payload[1] = (uint8_t)(sequence_counter & 0xFF);

	// Pack the Uptime Seconds (32-bit) into bytes 2 through 5 (Big-Endian)
    payload[2] = (uint8_t)((uptime_seconds >> 24) & 0xFF);
    payload[3] = (uint8_t)((uptime_seconds >> 16) & 0xFF);
    payload[4] = (uint8_t)((uptime_seconds >> 8) & 0xFF);
    payload[5] = (uint8_t)(uptime_seconds & 0xFF);

    // Fill the remaining 7 bytes with placeholders (or leave as 0)
    // You can replace these later with additional data like battery or temperature readings
    payload[6] = 0x00; // Placeholder
    payload[7] = 0x00;
    payload[8] = 0x00;
    payload[9] = 0x00;
    payload[10] = 0x00;
    payload[11] = 0x00;
    payload[12] = 0x00;

    // Increment sequence counter
    sequence_counter++;

    memset(&adv_raw_data[_ADV_BUFFER_POS], 0, sizeof(adv_raw_data) - _ADV_BUFFER_BASE_LEN);
    ret = hubble_ble_advertise_get(payload, sizeof(payload), &adv_raw_data[_ADV_BUFFER_POS], &out_len);
    if (ret != 0) {
        ESP_LOGE(DEMO_TAG, "Failed to get adv data");
        return ESP_FAIL;
    }

    adv_raw_data[_ADV_BUFFER_SERVICE_DATA_POS] = out_len + 1; /* +1 because of service data byte */

    ret = esp_ble_gap_config_adv_data_raw(adv_raw_data, _ADV_BUFFER_BASE_LEN + out_len);
    if (ret) {
        ESP_LOGE(DEMO_TAG, "config adv data failed, error code = %x", ret);
    }

    return ret;
}

Fetch and Parse Your Custom Payload from Hubble Cloud

Once you have provisioned, built, and flashed your firmware, your ESP32-C6 should start beaconing its custom payload to Hubble Cloud. In this step, you will automatically fetch your Hubble device packets and parse its payload so that it’s human readable (instead of binary or Base64 encoded).

Below is an example Python script you can use to parse the custom payload you defined in your firmware. It builds on the script we wrote in one of the previous tutorials mentioned earlier by adding a decode_payload() function to parse out the sequence number and uptime in seconds. Specifically, the code takes the Base64-encoded string from your payload and uses the native struct module to unpack the incoming binary fields using Big-Endian formatting rules.

import requests
import time
import base64
import struct
from datetime import datetime, timezone

# Configuration
ORG_ID = "INSERT_YOUR_ORG_ID_HERE"
DEVICE_ID = "INSERT_YOUR_DEVICE_ID_HERE"
API_KEY = "INSERT_YOUR_API_KEY_HERE"

URL = f"https://api.hubble.com/api/org/{ORG_ID}/packets"

def decode_payload(base64_str):
    """
    Decodes the Base64 payload and unpacks the 13-byte layout:
    Bytes 0-1: uint16 Sequence Number (Big-Endian)
    Bytes 2-5: uint32 Uptime Seconds (Big-Endian)
    """
    try:
        if not base64_str:
            return "Empty Payload"
            
        # Decode base64 to raw bytes
        raw_bytes = base64.b64decode(base64_str)
        
        # Ensure we have at least 6 bytes to extract sequence (2) and uptime (4)
        if len(raw_bytes) < 6:
            return f"Malformatted (Len: {len(raw_bytes)})"
            
        # Unpack Big-Endian structures:
        # '>' enforces Big-Endian
        # 'H' is a 2-byte unsigned short (Sequence Number)
        # 'I' is a 4-byte unsigned int (Uptime)
        sequence_num, uptime_secs = struct.unpack('>HI', raw_bytes[0:6])
        
        return str(sequence_num), str(uptime_secs)
        
    except Exception as e:
        return f"Parsing Error ({e})"

def fetch_packets():
    # Only pull packets from the last 5 minutes
    start_ts = int(time.time()) - 300
    
    # Define the parameters of your API request
    params = {
        "device_id": DEVICE_ID,
        "start": start_ts
    }
    
    # Define the headers of your API request
    headers = {"Authorization": f"Bearer {API_KEY}"}

    try:
        # Send the API request
        response = requests.get(URL, headers=headers, params=params)
        if response.status_code == 200:
            packets = response.json().get("packets", [])[:10]
            
            # Set up headers for the terminal output
            print(f"{'TIME (UTC)':<10} | {'RSSI':<5} | {'SEQUENCE NUMBER':<15} | {'UPTIME (SEC)'}")
            print("-" * 51)

            # Parse the packet data from the API response
            for p in packets:
                dev = p.get("device", {})
                ts = dev.get("timestamp", "N/A")
                rssi = dev.get("rssi", "N/A")
                data = dev.get("payload", "")

                # Reformat the timestamp into a human-readable format hours:minutes:seconds
                timestr = datetime.fromtimestamp(ts, tz=timezone.utc).strftime('%H:%M:%S')

                # Process raw Base64 payload into discrete metrics
                seq_num, uptime = decode_payload(data)

                # Print the packet data
                print(f"{timestr:<10} | {str(rssi):<5} | {seq_num:<15} | {uptime}")
    except Exception as e:
        print(f"Error: {e}")

if __name__ == "__main__":
    fetch_packets()

Results

If you followed these steps exactly, you should see something like the following:

Terminal output showing decoded Hubble packets with UTC time, RSSI, sequence number, and uptime columns

Next Steps: Real-World Applications

Now that you have established a reliable workflow for packaging and parsing raw binary payloads, you can move on toward specialized field deployment applications. Here are a few ways custom data frames are used in production environments:

  • Industrial Asset Tracking: Instead of sending empty packets, replace the remaining 7 zero-initialized bytes with localized sensor data. You can interface the ESP32-C6 with an external accelerometer to pack movement flags (e.g., stationary vs. in-motion) alongside GPS coordinates or localized gateway IDs to track heavy machinery, shipping containers, or fleet vehicles.
  • Cold Chain Environmental Monitoring: For temperature-sensitive logistics, such as pharmaceuticals or perishable food transport, you can connect an I2C digital thermometer to the DevKitC. Packing high-precision, 16-bit temperature values into your payload allows backend cloud systems to continuously audit thermal compliance.
  • Predictive Maintenance and Battery Analytics: Utilize the ESP32-C6’s internal Analog-to-Digital Converter (ADC) to read the active voltage curve of your device’s battery cell. By transmitting real-time millivolt drops alongside your uptime logs, your cloud platform can analyze state-of-charge decay patterns and flag field technicians before a remote node fails.
  • Geofencing and Security Beacons: Program the firmware to alter its transmission frequency or append emergency alert flags if the device loses connection with a primary Wi-Fi or Thread network. If an asset is moved outside its designated perimeter, the sequence tracker can rapidly accelerate its broadcast interval to assist recovery systems.

Ready to connect your devices anywhere on Earth? Get started with Hubble Network for free →