IoT Protocol (MQTT)¶
Inklet devices communicate with the backend over MQTT via AWS IoT Core. All connections use X.509 certificate mutual TLS (mTLS) for authentication. This page documents the topic structure, message formats, and security policies.
Connection Details¶
| Parameter | Value |
|---|---|
| Protocol | MQTT over TLS (port 8883) |
| Authentication | X.509 client certificates (mTLS) |
| Broker | AWS IoT Core (xxxx-ats.iot.us-east-1.amazonaws.com) |
| QoS | 1 (at least once delivery) |
Topic Structure¶
All topics follow the pattern inklet/dev/{thingName}/direction/type, where {thingName} is the AWS IoT Core Thing name assigned during provisioning.
inklet/dev/{thingName}/
├── up/ # Device → Backend
│ ├── heartbeat # Periodic health report
│ ├── state # Arbitrary device state
│ └── request_claim # Request a pairing code
└── down/ # Backend → Device
└── cmd # Commands from the backend
Uplink Topics (Device to Backend)¶
inklet/dev/{thingName}/up/heartbeat¶
Periodic health report sent by the device at a configurable interval (default: 30 seconds).
Payload:
{
"hwId": "a1b2c3d4-5678-9012-abcd-ef0123456789",
"ts": 1705395000,
"firmware": "1.2.0",
"battery": 85
}
| Field | Type | Description |
|---|---|---|
hwId |
string | Hardware UUID |
ts |
integer | Unix timestamp (seconds) |
firmware |
string | Firmware version string |
battery |
integer | Battery percentage (0--100) |
Backend Behavior:
- Creates the device record in the database if it does not exist (using
hwIdandthingName) - Updates
firmware,battery,lastSeenAt, andonlinestatus - If the device is unbound and has no claim code, the backend generates one and sends a
claim_codecommand
inklet/dev/{thingName}/up/state¶
Device reports arbitrary state as a JSON object. The backend stores this as a JSON string in the state column.
Payload:
{
"screen": "text",
"lastCmd": "01912345-9999-7abc-def0-aaaaaaaaaaaa",
"brightness": 50,
"temperature": 22.5
}
The payload can contain any valid JSON. The backend stores it without interpreting its contents.
Backend Behavior:
- Stores the full JSON payload as the device's
state - Updates
stateUpdatedAt
inklet/dev/{thingName}/up/request_claim¶
Device requests a claim code for user pairing. Sent when the device is unbound and needs to display a pairing code.
Payload:
Backend Behavior:
- If the device is already bound to a user, sends an
already_boundcommand - If the device is unbound, generates a 6-character alphanumeric claim code
- Stores the code in the database
- Sends a
claim_codecommand back to the device
Downlink Topics (Backend to Device)¶
inklet/dev/{thingName}/down/cmd¶
Commands from the backend to the device. All commands share a common structure with a kind field that determines the command type.
Command: text¶
Send text content for the device to render on its e-ink display.
| Field | Type | Description |
|---|---|---|
kind |
string | "text" |
id |
UUID | Unique command ID for tracking |
text |
string | Text content to render |
Command: claim_code¶
Send a pairing code for the device to display. Users enter this code to bind the device to their account.
| Field | Type | Description |
|---|---|---|
kind |
string | "claim_code" |
code |
string | 6-character alphanumeric claim code |
Command: bound¶
Notify the device that it has been bound to a user.
| Field | Type | Description |
|---|---|---|
kind |
string | "bound" |
userId |
UUID | ID of the user who bound the device |
Command: unbound¶
Notify the device that it has been unbound from its owner. The device should clear its screen and re-request a claim code.
| Field | Type | Description |
|---|---|---|
kind |
string | "unbound" |
Command: already_bound¶
Sent when a device requests a claim code but is already bound to a user. The device should not display a pairing screen.
| Field | Type | Description |
|---|---|---|
kind |
string | "already_bound" |
AWS IoT Core Policies¶
Three IAM policies govern MQTT access. Each enforces the principle of least privilege.
inklet-device-policy¶
Per-device isolation policy attached to each device certificate. Uses the IoT Core policy variable ${iot:Connection.Thing.ThingName} so a device can only access its own topics.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:us-east-1:*:client/${iot:Connection.Thing.ThingName}"
},
{
"Effect": "Allow",
"Action": "iot:Publish",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/inklet/dev/${iot:Connection.Thing.ThingName}/up/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": [
"arn:aws:iot:us-east-1:*:topicfilter/inklet/dev/${iot:Connection.Thing.ThingName}/down/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Receive",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/inklet/dev/${iot:Connection.Thing.ThingName}/down/*"
]
}
]
}
Per-Device Isolation
The ${iot:Connection.Thing.ThingName} variable is resolved by AWS IoT Core at connection time. A device certificate associated with Thing inklet-a1b2c3d4 can only publish to inklet/dev/inklet-a1b2c3d4/up/* and subscribe to inklet/dev/inklet-a1b2c3d4/down/*. It cannot access other devices' topics.
inklet-backend-policy¶
Privileged policy for the backend MQTT client (inklet-backend). The backend subscribes to all device uplink topics and publishes commands to any device's downlink topic.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:us-east-1:*:client/inklet-backend"
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": [
"arn:aws:iot:us-east-1:*:topicfilter/inklet/dev/+/up/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Receive",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/inklet/dev/+/up/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Publish",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/inklet/dev/+/down/*"
]
}
]
}
inklet-claim-policy¶
Restricted policy for claim certificates used during Fleet Provisioning. Claim certs can only perform the Fleet Provisioning MQTT transactions --- they cannot publish or subscribe to application topics.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "iot:Publish",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/$aws/certificates/create/*",
"arn:aws:iot:us-east-1:*:topic/$aws/provisioning-templates/*/provision/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": [
"arn:aws:iot:us-east-1:*:topicfilter/$aws/certificates/create/*",
"arn:aws:iot:us-east-1:*:topicfilter/$aws/provisioning-templates/*/provision/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Receive",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/$aws/certificates/create/*",
"arn:aws:iot:us-east-1:*:topic/$aws/provisioning-templates/*/provision/*"
]
}
]
}
Claim Certificate Security
Claim certificates are shared across all devices and only grant access to the Fleet Provisioning topics. They should be stored securely but are not as sensitive as per-device certificates --- a compromised claim cert can only create new Things, not impersonate existing ones.
Fleet Provisioning¶
New devices use Fleet Provisioning by Claim to obtain their device-specific certificates on first boot.
Flow:
Device (first boot)
│
├── Connects to IoT Core with claim certificate
│
├── Publishes to $aws/certificates/create/json
│ └── Receives new certificate + private key
│
├── Publishes to $aws/provisioning-templates/{template}/provision/json
│ └── Sends: { "SerialNumber": "{hwId}" }
│ └── Receives: { "thingName": "inklet-{prefix}" }
│
├── Stores device cert, private key, and thingName
│
└── Reconnects with device certificate
└── Begins normal operation (heartbeats, commands)
After provisioning, the device stores its certificates and Thing name locally and never uses the claim certificate again.