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. So0x03is 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
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
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
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
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
Recommended Delays
- 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
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
Related Documents
- 01_connection.md - BLE connection setup
- 03_telemetry_stream.md - Reading telemetry after authentication
- 04_set_mode.md - Control commands after authentication