Skip to content

Packet Trace: Authentication Sequence

This document shows a complete authentication sequence with byte-by-byte annotations.

Overview

⚠️ Corrected 2026-08-18 — this is not an authentication sequence

This document used to open: "After BLE connection, the pump requires a specific sequence of 'magic packets' to unlock full functionality. This sequence must be sent exactly as specified." Every part of that is wrong, and the byte annotations below were wrong in the way that produced it.

The APDU's second byte is 0booLLLLLL — operation in the top two bits, payload length in the low six. So 0x03 is GET with a 3-byte payload, not "SET operation, 3 data bytes". The three bytes after it are the payload, not a register address plus a value, which is where "register 0x9495, unlock code 0x96" came from. All four packets are reads: two GETs and two INFO queries. Reads cannot unlock anything.

Nor is the sequence required, or the repetition. Ten connection cycles sending none of it — two with the BLE bond cleared and re-paired, five across pump power cycles — reached full readiness, read every Class 7 string, and accepted Class 3 START and STOP with the motor confirmed running.

The per-packet annotations below are corrected. The Python example further down still sends the packets, because this client still does; it is not doing what its comments say it is doing. See esphome-alpha-hwr issue #174 for the decode, the captures and the removal.

After BLE connection, this client sends four frames before anything else. They are reads, and the sequence is retained here as a record of what the client does.

Authentication Flow

sequenceDiagram
    participant Client
    participant Pump

    Client->>Pump: Legacy Magic (x3)

    Client->>Pump: Class 10 operation-status read (x5)

    Client->>Pump: Extend 1

    Client->>Pump: Extend 2

    Pump-->>Client: Ready for Commands

Packet 1-3: Legacy Magic (Send 3 Times)

Hex Dump

27 07 E7 F8 02 03 94 95 96 EB 47

Byte-by-Byte Breakdown

Offset Byte Name Description
0 0x27 Start Byte Request frame marker
1 0x07 Length Total length = 7 bytes (up to last APDU byte)
2 0xE7 Service ID GENI service
3 0xF8 Source Client address
4 0x02 Class Class 2 (Register-based operations)
5 0x03 OpSpec GET, 3-byte payload (0b00 + length 3)
6 0x94 Data Item Item 148, unit_family
7 0x95 Data Item Item 149, unit_type
8 0x96 Data Item Item 150, unit_version
9 0xEB CRC High CRC-16-CCITT high byte
10 0x47 CRC Low CRC-16-CCITT low byte

Purpose

Reads the pump's identity. An ALPHA HWR answers 52 / 7 / 2.

Repetition

This client sends it 3 times. One send is sufficient — the reply is identical each time, and nothing consumes it in any case.

Expected Response

None (pump acknowledges silently).

Implementation

LEGACY_MAGIC = bytes.fromhex("2707e7f80203949596eb47")

for _ in range(3):
    await client.write_gatt_char(GENI_CHAR_UUID, LEGACY_MAGIC, response=False)
    await asyncio.sleep(0.05)  # Small delay between packets

Packet 4-8: Class 10 operation-status read (sent 5 times)

Hex Dump

27 07 E7 F8 0A 03 56 00 06 C5 5A

Byte-by-Byte Breakdown

Offset Byte Name Description
0 0x27 Start Byte Request frame
1 0x07 Length 7 bytes
2 0xE7 Service ID GENI service
3 0xF8 Source Client
4 0x0A Class Class 10 (DataObject)
5 0x03 OpSpec GET, 3-byte payload (0b00 + length 3)
6 0x56 Object Object 86
7 0x00 Sub ID High Sub 6, high byte
8 0x06 Sub ID Low Sub 6, low byte
9 0xC5 CRC High CRC-16-CCITT high byte
10 0x5A CRC Low CRC-16-CCITT low byte

Purpose

Reads the operation-status object, which answers with the control mode, operation mode and current setpoint. The same object is polled again in normal operation, so this read is redundant rather than enabling.

Repetition

Must be sent exactly 5 times in sequence.

Expected Response

None (pump acknowledges silently).

Implementation

CLASS10_UNLOCK = bytes.fromhex("2707e7f80a03560006c55a")

for _ in range(5):
    await client.write_gatt_char(GENI_CHAR_UUID, CLASS10_UNLOCK, response=False)
    await asyncio.sleep(0.05)

Packet 9: Extend 1

Hex Dump

27 05 E7 F8 05 C1 4B C3 82

Byte-by-Byte Breakdown

Offset Byte Name Description
0 0x27 Start Byte Request frame
1 0x05 Length 5 bytes
2 0xE7 Service ID GENI service
3 0xF8 Source Client
4 0x05 Class Class 5 (Extension protocol)
5 0xC1 OpSpec Extension command 1
6 0x4B Data Extension parameter
7 0xC3 CRC High CRC-16-CCITT high byte
8 0x82 CRC Low CRC-16-CCITT low byte

Purpose

First extension packet. Sent before Extend 2 (order documented in connection.md Step C, observed from Grundfos app).

Repetition

Send exactly once.

Expected Response

None.

Implementation

EXTEND_1 = bytes.fromhex("2705e7f805c14bc382")

await client.write_gatt_char(GENI_CHAR_UUID, EXTEND_1, response=False)
await asyncio.sleep(0.05)

Packet 10: Extend 2

Hex Dump

27 05 E7 F8 0B C1 0F D0 C3

Byte-by-Byte Breakdown

Offset Byte Name Description
0 0x27 Start Byte Request frame
1 0x05 Length 5 bytes
2 0xE7 Service ID GENI service
3 0xF8 Source Client
4 0x0B Class Class 11 (Session extension)
5 0xC1 OpSpec Extension command 2
6 0x0F Data Extension parameter
7 0xD0 CRC High CRC-16-CCITT high byte
8 0xC3 CRC Low CRC-16-CCITT low byte

Purpose

Second extension packet. Enables full access to all pump features.

Repetition

Send exactly once.

Expected Response

None.

Implementation

EXTEND_2 = bytes.fromhex("2705e7f80bc10fd0c3")

await client.write_gatt_char(GENI_CHAR_UUID, EXTEND_2, response=False)
await asyncio.sleep(0.1)

Complete Implementation

Python Example

import asyncio
from bleak import BleakClient

# Authentication packets (pre-calculated with CRC)
LEGACY_MAGIC = bytes.fromhex("2707e7f80203949596eb47")
CLASS10_UNLOCK = bytes.fromhex("2707e7f80a03560006c55a")
EXTEND_1 = bytes.fromhex("2705e7f805c14bc382")
EXTEND_2 = bytes.fromhex("2705e7f80bc10fd0c3")

# BLE UUIDs
GENI_SERVICE_UUID = "0000fdd0-0000-1000-8000-00805f9b34fb"
GENI_CHAR_UUID = "859cffd1-036e-432a-aa28-1a0085b87ba9"  # write + notify


async def authenticate(client: BleakClient):
    """Perform full authentication sequence."""

    # Step 1: Legacy Magic (3x)
    print("Sending Legacy Magic packets...")
    for i in range(3):
        await client.write_gatt_char(
            GENI_CHAR_UUID, LEGACY_MAGIC, response=False
        )
        print(f"  Sent {i + 1}/3: {LEGACY_MAGIC.hex(' ')}")
        await asyncio.sleep(0.05)

    # Step 2: Class 10 Unlock (5x)
    print("Sending Class 10 Unlock packets...")
    for i in range(5):
        await client.write_gatt_char(
            GENI_CHAR_UUID, CLASS10_UNLOCK, response=False
        )
        print(f"  Sent {i + 1}/5: {CLASS10_UNLOCK.hex(' ')}")
        await asyncio.sleep(0.05)

    # Step 3: Extend 1 (1x)
    print("Sending Extend 1...")
    await client.write_gatt_char(GENI_CHAR_UUID, EXTEND_1, response=False)
    print(f"  Sent: {EXTEND_1.hex(' ')}")
    await asyncio.sleep(0.05)

    # Step 4: Extend 2 (1x)
    print("Sending Extend 2...")
    await client.write_gatt_char(GENI_CHAR_UUID, EXTEND_2, response=False)
    print(f"  Sent: {EXTEND_2.hex(' ')}")
    await asyncio.sleep(0.1)

    print("Authentication complete!")


async def main():
    # Connect to pump
    address = "XX:XX:XX:XX:XX:XX"  # Replace with pump address

    async with BleakClient(address) as client:
        print(f"Connected to {address}")

        # Perform authentication
        await authenticate(client)

        # Now ready for commands
        print("Pump is now authenticated and ready for commands")


if __name__ == "__main__":
    asyncio.run(main())

JavaScript Example

// Authentication packets
const LEGACY_MAGIC = new Uint8Array([0x27, 0x07, 0xE7, 0xF8, 0x02, 0x03, 0x94, 0x95, 0x96, 0xEB, 0x47]);
const CLASS10_UNLOCK = new Uint8Array([0x27, 0x07, 0xE7, 0xF8, 0x0A, 0x03, 0x56, 0x00, 0x06, 0xC5, 0x5A]);
const EXTEND_1 = new Uint8Array([0x27, 0x05, 0xE7, 0xF8, 0x05, 0xC1, 0x4B, 0xC3, 0x82]);
const EXTEND_2 = new Uint8Array([0x27, 0x05, 0xE7, 0xF8, 0x0B, 0xC1, 0x0F, 0xD0, 0xC3]);

async function authenticate(txCharacteristic) {
  // Step 1: Legacy Magic (3x)
  for (let i = 0; i < 3; i++) {
    await txCharacteristic.writeValue(LEGACY_MAGIC);
    await sleep(50);
  }

  // Step 2: Class 10 Unlock (5x)
  for (let i = 0; i < 5; i++) {
    await txCharacteristic.writeValue(CLASS10_UNLOCK);
    await sleep(50);
  }

  // Step 3: Extend 1
  await txCharacteristic.writeValue(EXTEND_1);
  await sleep(50);

  // Step 4: Extend 2
  await txCharacteristic.writeValue(EXTEND_2);
  await sleep(100);

  console.log("Authentication complete");
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Timing Considerations

  • Between packets: 50ms minimum
  • After Extend 2: 100ms minimum
  • Before first command: 200ms recommended

Rationale

  • Pump needs time to process each packet
  • Too fast: Packets may be dropped
  • Too slow: No problem, but slower authentication

Total Time

Typical authentication takes ~1 second total.


Validation

How to Verify Authentication Worked

Test 1: Send telemetry request

# After authentication, this should work:
info_cmd = build_info_command(class_byte=0x0A, sub_id=0x0045, obj_id=0x0057)
await client.write_gatt_char(GENI_CHAR_UUID, info_cmd, response=False)

# Should receive telemetry response

Test 2: Check for error responses

If authentication failed:
- Commands will timeout (no response)
- OR pump sends NACK/error response

Test 3: Try control command

# Should be able to set mode after authentication
set_cmd = build_set_command(sub=0x5600, obj=0x0601, value=...)
await client.write_gatt_char(GENI_CHAR_UUID, set_cmd, response=False)
# Should receive ACK


Common Issues

Issue 1: Wrong Packet Count

Symptom: Commands don't work after authentication. Cause: Sent wrong number of Legacy Magic or Class 10 Unlock packets. Fix: Must be exactly 3 and 5 respectively.

Issue 2: Wrong Packet Order

Symptom: Authentication fails silently. Cause: Sent packets out of order. Fix: Follow exact sequence: Legacy (3x) → Class10 (5x) → Extend1 → Extend2

Issue 3: CRC Error

Symptom: Pump rejects authentication packets. Cause: Typo in packet bytes. Fix: Use exact bytes from this document, don't recalculate.

Issue 4: Packets Too Fast

Symptom: Some authentication packets dropped. Cause: No delay between packets. Fix: Add 50ms delay between each packet.


Security Note

This authentication sequence uses security through obscurity: - No cryptographic challenge-response - No shared secrets - Packets are fixed (anyone can replay them)

Recommendation: - Use BLE pairing for true security - Don't rely on this for access control - Assume anyone with BLE access can control pump


Reference

See Python implementation: - src/alpha_hwr/core/authentication.py - Complete implementation with detailed comments - tests/core/test_authentication.py - Test suite