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â
- Device Creation Flow
- Device Claim Flow
- First Device Test
- First Command Flow
- State Management
- 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 startinhxtp/)
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)â
- Device enters SoftAP provisioning mode
- User connects to
HXTP_Device_XXXXXWiFi - Opens
192.168.4.1in browser - Enters WiFi SSID and password
- Submits claim code from QR
- Device computes Ed25519 keypair
- Sends claim request to backend
- Backend verifies signature
- Device transitions PENDING_CLAIM â CLAIMED
- Device connects to MQTT broker
- Device sends HELLO message
- Backend verifies HELLO signature
- 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â
| Error | Cause | Recovery |
|---|---|---|
CLAIM_SIGNATURE_INVALID | Device sent bad claim signature | Device must retry with correct key |
HELLO_SIGNATURE_INVALID | Device HELLO signature verification failed | Resend HELLO, check device key |
DEVICE_REVOKED | Admin blocked device | Remove from revocation list or factory reset |
MQTT_CONNECTION_FAILED | Network unreachable | Check WiFi, check MQTT broker |
NONCE_COLLISION | Replay attack detected | Device should generate new nonce and retry |
SESSION_TOKEN_EXPIRED | MQTT session expired | Device 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