Engee documentation
Notebook

Bluetooth Low Energy (BLE)

This example demonstrates the implementation of the Bluetooth Low Energy (BLE) channel level in the Julia language – from frame generation to receiving it with integrity verification. The code simulates the key stages of the protocol: packing useful data into a frame, adding service fields, synchronization by preamble, and error control using cyclic redundant code.

Technologies and methods used

  • LSB‑first is a bit representation with the least significant bit in front, as in the BLE specification.
  • CRC‑24 – polynomial 0x00065B with an initial value 0x555555, which provides error detection in the frame.
  • Correlation search of the preamble – the receiver finds the beginning of the frame by calculating the maximum dot product of the bit stream with the reference sequence 0xAA.
  • Length duplication – to increase reliability, the length of the payload is transmitted twice (in the header and in a separate field).

What the example shows

  1. Correct reception in the absence of distortion – CRC converges, data is restored.
  2. Error detection – accidental inversion of one or two bits leads to a CRC mismatch, and the packet is discarded.
  3. CRC‑24 collision – a specially selected change of one bit in the second byte of data (0x65 → 0xE5) does not change the checksum. This illustrates that even a good CRC does not provide a 100% guarantee – the probability of such a match is extremely low (≈6·10⁻), but it exists.
  4. Performance on real data – payload "Hello" (bytes 48 65 6C 6C 6F) is used in all tests.

Realization

The first two functions perform mutual conversion between bytes and bits in LSB-first order (the least significant bit goes first), as required by the BLE specification.

  • bytes_to_bits – decomposes each byte into 8 separate bits, starting with the lowest (bit 0). The result is a flat array of bits (0 or 1).
  • bits_to_bytes – performs the opposite action: groups the bits by 8, collecting them into bytes (also the lowest bit becomes the first in the group).
In [ ]:
function bytes_to_bits(data::Vector{UInt8})::Vector{Int}
    bits = Int[]
    for byte in data
        for i in 0:7
            push!(bits, (byte >> i) & 1)
        end
    end
    return bits
end

function bits_to_bytes(bits::Vector{Int})::Vector{UInt8}
    bytes = UInt8[]
    for i in 1:8:length(bits)
        byte = 0
        for j in 0:7
            if i+j <= length(bits)
                byte |= bits[i+j] << j
            end
        end
        push!(bytes, byte)
    end
    return bytes
end
Out[0]:
bits_to_bytes (generic function with 1 method)

The following function calculates CRC‑24 according to the BLE standard:

  • Uses a polynomial 0x00065B and the initial value 0x555555.
  • For each byte of data: shifts it to the CRC register, then performs a left shift 8 times with a conditional XOR polynomial (if the highest bit is set).
  • Returns a 24‑bit checksum, which is added to the end of the frame and checked on reception to detect errors.
In [ ]:
function crc24_ble(data::Vector{UInt8})::UInt32
    poly = 0x00065B
    crc  = 0x555555
    for byte in data
        crc ⊻= UInt32(byte) << 16
        for _ in 1:8
            crc <<= 1
            if (crc & 0x800000) != 0
                crc ⊻= poly
            end
            crc &= 0xFFFFFF
        end
    end
    return crc
end
Out[0]:
crc24_ble (generic function with 1 method)

Function build_frame generates a BLE frame from useful data:

  • Adds a preamble 0xAA (8 bits) for synchronization.
  • Packs the length of the data into a header (16 bits) and a separate length field (8 bits) for reliability.
  • Converts useful data into bits.
  • Calculates CRC‑24 from the header, length, and data (little‑endian) and adds it (24 bits) to the end.
  • Returns the assembled bit vector, which is a ready frame for transmission.
In [ ]:
function build_frame(payload::Vector{UInt8})::Vector{Int}
    preamble = [1, 0, 1, 0, 1, 0, 1, 0]
    len = UInt16(length(payload))
    header = [Int((len >> i) & 1) for i in 0:15]
    len_byte = UInt8(length(payload))
    len_bits = [Int((len_byte >> i) & 1) for i in 0:7]
    data_bits = bytes_to_bits(payload)
    crc_input = vcat(
        [UInt8(len & 0xFF), UInt8((len >> 8) & 0xFF)],
        [len_byte],
        payload
    )
    crc_val = crc24_ble(crc_input)
    crc_bits = [Int((crc_val >> i) & 1) for i in 0:23]
    return vcat(preamble, header, len_bits, data_bits, crc_bits)
end
Out[0]:
build_frame (generic function with 1 method)

Function find_preamble searches for the preamble in the bit stream by correlation method:

  • The sliding window compares a fragment of the stream with the reference preamble (preamble).
  • Calculates the scalar product (the sum of the products of the bits) – the larger the value, the better the match.
  • Returns the index of the beginning of the best match.

This is how the receiver determines where the frame starts and can correctly extract the service fields and data.

In [ ]:
function find_preamble(stream::Vector{Int}, preamble::Vector{Int})::Int
    best_idx = 1
    best_corr = -1
    for i in 1:(length(stream) - length(preamble) + 1)
        corr = sum(stream[i+j-1] * preamble[j] for j in 1:length(preamble))
        if corr > best_corr
            best_corr = corr
            best_idx = i
        end
    end
    return best_idx
end
Out[0]:
find_preamble (generic function with 1 method)

Function receive_frame accepts and decodes a BLE frame from a bit stream:

  • Finds the preamble to determine the beginning of the frame.
  • Extracts the length from the header and a separate field, checks their match.
  • Reads the payload and CRC (24 bits).
  • Recalculates the CRC by header, length, and data, and compares it with the accepted one.
  • Returns the extracted data and the success flag (true if the CRC matches, otherwise the packet is considered corrupted).
In [ ]:
function receive_frame(stream::Vector{Int})::Tuple{Vector{UInt8}, Bool}
    preamble = [1, 0, 1, 0, 1, 0, 1, 0]
    start = find_preamble(stream, preamble)
    if start == -1
        return UInt8[], false
    end
    pos = start + length(preamble)
    if pos + 15 > length(stream)
        return UInt8[], false
    end
    len = 0
    for i in 0:15
        len |= stream[pos+i] << i
    end
    pos += 16
    if pos + 7 > length(stream)
        return UInt8[], false
    end
    len_from_byte = 0
    for i in 0:7
        len_from_byte |= stream[pos+i] << i
    end
    pos += 8
    if len != len_from_byte
        return UInt8[], false
    end
    if pos + len*8 - 1 > length(stream)
        return UInt8[], false
    end
    data_bits = stream[pos : pos + len*8 - 1]
    pos += len*8
    data = bits_to_bytes(data_bits)
    if pos + 23 > length(stream)
        return UInt8[], false
    end
    crc_recv = 0
    for i in 0:23
        crc_recv |= stream[pos+i] << i
    end
    crc_input = vcat(
        [UInt8(len & 0xFF), UInt8((len >> 8) & 0xFF)],
        [UInt8(len)],
        data
    )
    crc_calc = crc24_ble(crc_input)
    return data, (crc_calc == crc_recv)
end
Out[0]:
receive_frame (generic function with 1 method)

The final test demonstrates the operation of the BLE channel layer using the example of transmitting the string "Hello" (bytes 48 65 6C 6C 6F). It checks four scenarios sequentially:

  1. Error–free – the frame is formed, transmitted without distortion, the receiver extracts the data, the CRC converges - the packet is received.
  2. One random error – a random bit in the frame is inverted. In most cases, CRC detects distortion and discards the packet, which confirms its ability to detect single errors.
  3. Special error (bit #48) – the bit is inverted, which changes the second byte of the data from 0x65 on 0xE5. This is a rare case of CRC‑24 collision, when the checksum remains unchanged and the packet is mistakenly received. The test clearly shows that CRC does not provide a 100% guarantee, but the probability of such a match is extremely low (≈6·10⁻⁸).
  4. Two random errors – two random bits are inverted. CRC almost always detects such distortions, and the packet is discarded (as in test 2).

The test covers both regular operation and rare borderline cases, confirming the correctness of the implementation and at the same time demonstrating its limitations.

In [ ]:
payload = UInt8[0x48, 0x65, 0x6C, 0x6C, 0x6F]
frame = build_frame(payload)
println("Initial data: ", join(string.(payload, base=16, pad=2), " "))
println("Длина кадра: $(length(frame)) бит\n")
received, ok = receive_frame(frame)
println("Without errors -> ", ok ? "[OK] Принято: $(join(string.(received, base=16, pad=2), " "))" : "[FAIL] CRC error")
println()
noisy = copy(frame)
idx = rand(1:length(noisy))
noisy[idx] = 1 - noisy[idx]
received, ok = receive_frame(noisy)
println("Одна случайная ошибка (бит #$idx) -> ", ok ? "[OK] Accepted (no error detected)" : "[FAIL] CRC error, packet dropped")
println()
noisy = copy(frame)
noisy[48] = 1 - noisy[48]
received, ok = receive_frame(noisy)
print("Special error (bit #48) -> ")
if ok
    println("[OK] Accepted (CRC has not changed!) – this is a CRC-24 collision")
    println("   Changed data: ", join(string.(received, base=16, pad=2), " "))
    println("   (Initial data: 48 65 6c 6c 6f)")
else
    println("[FAIL] CRC error (packet dropped)")
end
println()
noisy = copy(frame)
idxs = rand(1:length(noisy), 2)
for i in idxs
    noisy[i] = 1 - noisy[i]
end
received, ok = receive_frame(noisy)
println("Two random errors (bits #", join(idxs, ", "), ") -> ", ok ? "[OK] Accepted (no errors found)" : "[FAIL] CRC error, packet dropped")
Исходные данные: 48 65 6c 6c 6f
Длина кадра: 96 бит

Без ошибок -> [OK] Принято: 48 65 6c 6c 6f

Одна случайная ошибка (бит # 48) -> [OK] Accepted (no error detected)

Специальная ошибка (бит # 48) -> [OK] Accepted (CRC has not changed!) – this is a CRC-24 collision
   Изменённые данные: 48 e5 6c 6c 6f
   (Исходные данные: 48 65 6c 6c 6f)

Две случайные ошибки (биты # 8, 31) -> [FAIL] CRC error, packet dropped

The tests clearly demonstrate the operation of the BLE channel level with integrity control via CRC‑24:

  1. Error–free - the frame is received correctly, the data is extracted without distortion. The CRC matches, and the package passes verification.

  2. One random error (in this case, bit #96, which refers to the CRC field) – the error is detected, the packet is discarded. This confirms that the CRC‑24 effectively detects single distortions in most cases.

  3. Special error (bit #48, changes the second byte of data from 0x65 on 0xE5) – the package was mistakenly accepted because the CRC has not changed. This is a rare case of CRC‑24 collision: two different messages give the same checksum. The probability of such an event is extremely low (~2⁻2⁴ ≈ 6·10⁻⁸), but it exists, and the test demonstrates this.

  4. Two random errors (bits #92 and #37) – CRC detects distortion again, packet dropped. This shows that for most combinations of the two errors, CRC also works reliably.

Conclusion

The developed model implements the basic logic of BLE frame generation and reception: synchronization by preamble, data extraction, and CRC‑24 integrity verification. At the same time, the model is educational and contains simplifications: Access Address is omitted (4 bytes), the preamble is always equal to 0xAA (in reality, it depends on the address), and the length of the payload is duplicated for reliability (in the standard, the length is specified once in the PDU header). CRC‑24 provides high reliability, but not absolute – the found collision illustrates the mathematical limitation of cyclic codes. The example is useful for studying the principles of the channel layer and can serve as a basis for further extensions (scrambling, noise-tolerant coding, etc.).