Skip to main content

HxTP/3.1 Frontend Device Onboarding Guide

For Cloud Dashboard Developers

This guide explains how to build frontend flows for device provisioning, claiming, and activation.


Table of Contents​

  1. Device Creation Flow
  2. Device Claim Flow
  3. First Device Test
  4. First Command Flow
  5. State Management
  6. Error Handling

Device Creation Flow​

Overview​

Creating a device in the system establishes the device record and initiates the claim flow.

User clicks "Add Device"
↓
Frontend: POST /api/devices/create
↓
Backend: Generate device_id = uuidv5(public_key)
↓
Backend: Create device record with state=PENDING_CLAIM
↓
Backend: Generate claim_session (ephemeral)
↓
Frontend: Display QR code with claim token
↓
Device: Scan QR and load claim_session
↓
Device: Generate Ed25519 keypair (stored in NVS)
↓
Device: Sign claim material with private_key
↓
User: Scan QR or manually enter claim code
↓
Backend: Verify signature
↓
Device state transitions: PENDING_CLAIM → CLAIMED

Step-by-Step​

1. Frontend: Display Device Creation Form​

// components/AddDeviceWizard.tsx
export function AddDeviceWizard() {
const [deviceName, setDeviceName] = useState("");
const [deviceType, setDeviceType] = useState("relay");
const [createdDevice, setCreatedDevice] = useState(null);

async function handleCreate() {
const response = await fetch("/api/v1/devices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: deviceName,
device_type: deviceType,
tenant_id: currentTenantId, // from auth context
}),
});

const device = await response.json();
setCreatedDevice(device);
// display QR code with device.claim_session
}

return (
<form onSubmit={handleCreate}>
<input
placeholder="Device Name"
value={deviceName}
onChange={(e) => setDeviceName(e.target.value)}
/>
<select value={deviceType} onChange={(e) => setDeviceType(e.target.value)}>
<option value="relay">Smart Relay</option>
<option value="hx47">HX47 Hub</option>
<option value="thermostat">Thermostat</option>
</select>
<button type="submit">Create Device</button>

{createdDevice && (
<div>
<h3>Scan to Claim</h3>
<QRCode value={createdDevice.claim_session} size={256} />
<p>Device ID: {createdDevice.device_id}</p>
<p>Status: {createdDevice.state}</p>
</div>
)}
</form>
);
}

2. Backend: Device Creation Endpoint​

// POST /api/v1/devices
async function createDevice(req: Request, res: Response) {
const { name, device_type, tenant_id } = req.body;

// 1. Generate device_id from public_key (uuidv5)
// Note: Device hasn't provided its public key yet
// Use temporary device_id; will be updated when device sends claim
const tempDeviceId = uuidv4();

// 2. Generate claim_session (ephemeral, 1-hour TTL)
const claimSession = crypto.randomBytes(32).toString("hex");

// 3. Create device record
const device = await db.devices.create({
device_id: tempDeviceId,
tenant_id,
name,
device_type,
state: "PENDING_CLAIM",
public_key: null, // will be filled during claim
created_at: new Date(),
claim_session,
claim_session_expires: Date.now() + 3600000, // 1 hour
});

res.json({
device_id: device.device_id,
claim_session: device.claim_session,
state: device.state,
});
}

Device Claim Flow​

Overview​

Device claims itself by signing a claim token with its Ed25519 private key.

Device receives claim_session (via QR)
↓
Device: Generate Ed25519 keypair (seeded, deterministic)
↓
Device: Compute device_id = uuidv5(public_key)
↓
Device: Build claim_material = {
device_id,
tenant_id,
public_key,
timestamp
}
↓
Device: Sign with Ed25519 private key
↓
Device: POST /api/bootstrap with (claim_session, signature, public_key)
↓
Backend: Verify signature
↓
Backend: Update device record:
- public_key = extracted_public_key
- device_id = uuidv5(public_key)
- state = CLAIMED
↓
Device: Store tenant_id, mqtt_host, mqtt_port
↓
Device: Transition to MQTT_LINKING state

Frontend Display​

// components/DeviceClaimStatus.tsx
export function DeviceClaimStatus({ deviceId }: { deviceId: string }) {
const [device, setDevice] = useState(null);

useEffect(() => {
// Poll device status every 5 seconds
const timer = setInterval(async () => {
const res = await fetch(`/api/v1/devices/${deviceId}`);
const updated = await res.json();
setDevice(updated);

if (updated.state === "ACTIVE") {
clearInterval(timer); // stop polling
}
}, 5000);

return () => clearInterval(timer);
}, [deviceId]);

return (
<div>
<h2>Device Status: {device?.state}</h2>
<ul>
<li>
✓ PENDING_CLAIM — Waiting for device to scan QR
</li>
<li className={device?.state === "CLAIMED" ? "done" : "pending"}>
{device?.state === "CLAIMED" ? "✓" : "○"} CLAIMED — Device
received claim
</li>
<li className={device?.state === "ACTIVE" ? "done" : "pending"}>
{device?.state === "ACTIVE" ? "✓" : "○"} ACTIVE — Device
connected and ready
</li>
</ul>
</div>
);
}

First Device Test​

Objective​

Test complete onboarding flow from device creation through first command.

Prerequisites​

  • Development machine with Go CLI (hxtp-cli)
  • Local MQTT broker (mosquitto or Docker docker-compose up)
  • Micro SDK compiled for ESP32 or ESP8266
  • Backend running (npm start in hxtp/)

Complete Flow​

1. Create Device via Dashboard​

curl -X POST http://localhost:3000/api/v1/devices \
-H "Content-Type: application/json" \
-d '{
"name": "Test Relay 1",
"device_type": "relay",
"tenant_id": "tenant-test-001"
}'

# Response:
# {
# "device_id": "550e8400-e29b-41d4-a716-446655440000",
# "claim_session": "abc123def456ghi789jkl",
# "state": "PENDING_CLAIM"
# }

2. Flash Device with Micro SDK​

// examples/FirstDeviceTest.ino
#include "hxtp-micro.h"

const hxtp::Config DEVICE_CONFIG = {
// These will be filled after provisioning
.device_id = "",
.public_key = "",
.private_key = "",
.mqtt_host = "mosquitto.local",
.mqtt_port = 8883,
};

void setup() {
Serial.begin(115200);
delay(2000); // Wait for Serial

hxtp::Client client(DEVICE_CONFIG);
client.begin();

// 1. Start provisioning (SoftAP)
if (WiFi.status() != WL_CONNECTED) {
client.startProvisioning();
}

// 2. Connect to WiFi
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
}

// 3. Bootstrap (get MQTT session token)
client.connect();

// 4. Send HELLO
client.sayHello();

// State machine will advance to ACTIVE
}

void loop() {
client.loop(); // 20ms heartbeat

// Once ACTIVE, can receive commands
if (client.isActive()) {
// Register capabilities (relay control)
client.registerCapability(1, "relay:toggle", [](const auto& cmd) {
int relay_id = cmd.relay_id;
digitalWrite(RELAY_PIN, !digitalRead(RELAY_PIN));
Serial.println("Relay toggled");
});
}
}

3. Device Flow (Automatic via SoftAP)​

  1. Device enters SoftAP provisioning mode
  2. User connects to HXTP_Device_XXXXX WiFi
  3. Opens 192.168.4.1 in browser
  4. Enters WiFi SSID and password
  5. Submits claim code from QR
  6. Device computes Ed25519 keypair
  7. Sends claim request to backend
  8. Backend verifies signature
  9. Device transitions PENDING_CLAIM → CLAIMED
  10. Device connects to MQTT broker
  11. Device sends HELLO message
  12. Backend verifies HELLO signature
  13. Device transitions CLAIMED → ACTIVE

4. Monitor Status​

# Terminal 1: Watch device state
curl http://localhost:3000/api/v1/devices/550e8400-e29b-41d4-a716-446655440000

# Response (should progress):
# {"state": "PENDING_CLAIM"}
# → {"state": "CLAIMED"}
# → {"state": "ACTIVE"}

First Command Flow​

Objective​

Send first relay toggle command from dashboard and verify device response.

Prerequisites​

  • Device is in ACTIVE state
  • Device is listening for commands
  • Backend has device capability registered

Step-by-Step​

1. Frontend: Send Command​

// components/DeviceControl.tsx
export function RelayControl({ deviceId }: { deviceId: string }) {
const [isLoading, setIsLoading] = useState(false);
const [lastCommand, setLastCommand] = useState(null);

async function handleToggle() {
setIsLoading(true);
try {
const response = await fetch(`/api/v1/devices/${deviceId}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "relay:toggle",
relay_id: 1,
}),
});

const result = await response.json();
setLastCommand(result);
console.log("Command sent:", result.command_id);
} finally {
setIsLoading(false);
}
}

return (
<div>
<button onClick={handleToggle} disabled={isLoading}>
{isLoading ? "Sending..." : "Toggle Relay"}
</button>
{lastCommand && <p>Command ID: {lastCommand.command_id}</p>}
</div>
);
}

2. Backend: Command Endpoint​

// POST /api/v1/devices/{deviceId}/command
async function sendCommand(req: Request, res: Response) {
const { deviceId } = req.params;
const { action, relay_id } = req.body;

// 1. Verify device exists and is ACTIVE
const device = await db.devices.findById(deviceId);
if (device.state !== "ACTIVE") {
return res.status(409).json({ error: "Device not active" });
}

// 2. Build command message
const commandMessage = {
version: "HxTP/3.1",
device_id: deviceId,
tenant_id: device.tenant_id,
client_id: "backend-command-dispatch",
message_id: uuidv4(),
request_id: `req-cmd-${Date.now()}`,
sequence_number: device.next_sequence++,
timestamp: Date.now(),
nonce: generateNonce(),
message_type: "command",
params: { action, relay_id },
};

// 3. Compute payload hash
commandMessage.payload_hash = sha256(
JSON.stringify(commandMessage.params)
);

// 4. Sign with cloud root key (Ed25519)
commandMessage.signature = signEd25519(
CLOUD_ROOT_PRIVATE_KEY,
buildCanonical(commandMessage)
);

// 5. Publish to MQTT: hx/{tenant_id}/{device_id}/command
await mqttPublish(
`hx/${device.tenant_id}/${deviceId}/command`,
JSON.stringify(commandMessage)
);

res.json({
command_id: commandMessage.message_id,
status: "dispatched",
});
}

3. Device: Receive and Execute​

// Device MQTT callback (in Micro SDK)
void mqtt_message_handler(const char* topic, const uint8_t* data, int len) {
// 1. Parse JSON
StaticJsonDocument<512> doc;
deserializeJson(doc, data, len);

// 2. Verify message type
const char* msg_type = doc["message_type"];
if (strcmp(msg_type, "command") != 0) return;

// 3. Rebuild canonical string
std::string canonical = buildCanonical(doc);

// 4. Verify signature with cloud root public key
const char* signature = doc["signature"];
if (!verifyEd25519(CLOUD_ROOT_PUBLIC_KEY, canonical, signature)) {
Serial.println("Command signature verification failed!");
return;
}

// 5. Extract action and parameters
const char* action = doc["params"]["action"];
int relay_id = doc["params"]["relay_id"];

// 6. Execute action
if (strcmp(action, "relay:toggle") == 0) {
digitalWrite(RELAY_PIN, !digitalRead(RELAY_PIN));
Serial.println("Relay toggled via command");
}

// 7. Send ACK (optional)
sendCommandAck(doc["message_id"]);
}

State Management​

Device Lifecycle States​

┌─────────────────┐
│ IDLE/RESET │
└────────â”Ŧ────────┘
│ (factory reset or first boot)
↓
┌─────────────────┐
│ PROVISIONING │ (SoftAP, WiFi config)
└────────â”Ŧ────────┘
│
↓
┌─────────────────┐
│ PENDING_CLAIM │ (awaiting claim token)
└────────â”Ŧ────────┘
│ (user scans QR, device signs claim)
↓
┌─────────────────┐
│ CLAIMED │ (identity verified, awaiting HELLO)
└────────â”Ŧ────────┘
│
↓
┌─────────────────┐
│ MQTT_LINKING │ (connecting to MQTT broker)
└────────â”Ŧ────────┘
│
↓
┌─────────────────┐
│ HELLO_SENT │ (waiting for HELLO_ACK from backend)
└────────â”Ŧ────────┘
│
↓
┌─────────────────┐
│ ACTIVE │ (ready to receive commands)
└────────â”Ŧ────────┘
│
├─────→ RECONNECTING (lost MQTT connection)
│ ↓
└─────← (resend HELLO)

↓
REVOKED (permanently blocked by admin)

Frontend State Display​

// components/DeviceStateIndicator.tsx
const STATE_COLORS = {
IDLE: "gray",
PROVISIONING: "yellow",
PENDING_CLAIM: "orange",
CLAIMED: "blue",
MQTT_LINKING: "purple",
HELLO_SENT: "cyan",
ACTIVE: "green",
RECONNECTING: "yellow",
REVOKED: "red",
ERROR_STATE: "red",
};

export function DeviceStateIndicator({ state }: { state: string }) {
return (
<span style={{ color: STATE_COLORS[state] }}>
{state}
</span>
);
}

Error Handling​

Common Errors & Recovery​

ErrorCauseRecovery
CLAIM_SIGNATURE_INVALIDDevice sent bad claim signatureDevice must retry with correct key
HELLO_SIGNATURE_INVALIDDevice HELLO signature verification failedResend HELLO, check device key
DEVICE_REVOKEDAdmin blocked deviceRemove from revocation list or factory reset
MQTT_CONNECTION_FAILEDNetwork unreachableCheck WiFi, check MQTT broker
NONCE_COLLISIONReplay attack detectedDevice should generate new nonce and retry
SESSION_TOKEN_EXPIREDMQTT session expiredDevice should reconnect and resend HELLO

Frontend Error Display​

async function handleCommandError(error) {
const messages = {
"DEVICE_NOT_ACTIVE": "Device is not ready. Please wait...",
"COMMAND_REJECTED": "Device rejected the command",
"SIGNATURE_INVALID": "Backend signature verification failed",
"TIMEOUT": "Command timed out",
};

const userMessage = messages[error.code] || "Unknown error";
showErrorToast(userMessage);
}

Debugging Checklist​

  • Device successfully connects to WiFi
  • Device receives claim_session via QR
  • Device generates Ed25519 keypair
  • Backend receives claim request
  • Signature verification passes
  • Device state transitions to CLAIMED
  • Device connects to MQTT broker
  • Device sends HELLO message
  • Backend receives HELLO
  • HELLO signature verifies
  • Device state transitions to ACTIVE
  • Dashboard shows device as ACTIVE
  • Can send first command
  • Device receives and executes command