Skip to content

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

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:

  1. Creates the device record in the database if it does not exist (using hwId and thingName)
  2. Updates firmware, battery, lastSeenAt, and online status
  3. If the device is unbound and has no claim code, the backend generates one and sends a claim_code command

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:

  1. Stores the full JSON payload as the device's state
  2. 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:

  1. If the device is already bound to a user, sends an already_bound command
  2. If the device is unbound, generates a 6-character alphanumeric claim code
  3. Stores the code in the database
  4. Sends a claim_code command back to the 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.

{
  "kind": "text",
  "id": "01912345-9999-7abc-def0-aaaaaaaaaaaa",
  "text": "Hello from Inklet!"
}
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.

{
  "kind": "claim_code",
  "code": "A3X9K2"
}
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.

{
  "kind": "bound",
  "userId": "01912345-0000-7abc-def0-000000000001"
}
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.

{
  "kind": "unbound"
}
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.

{
  "kind": "already_bound"
}
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.