Status: Draft Last Updated: 2025-11-05 Protocol Version: 2.0
This document specifies the HTTP API that a content server must implement to provide content to the FlipDot driver. The driver is a lightweight client that polls for content and displays it on physical flipdot hardware.
Content Server (Stateless)
↓ HTTP/JSON (Poll)
Driver (Raspberry Pi)
↓ Serial
FlipDot Hardware
The content server is responsible for:
The driver is responsible for:
Endpoint: GET {poll_endpoint}
Purpose: Driver polls this endpoint to fetch current content
Method: GET
Headers:
User-Agent: FlipDot-Driver/2.0
Content-Type: application/json
Authentication Headers (choose one):
Authorization: Bearer {token}
OR
{header_name}: {api_key}
(Default header_name: X-API-Key)
Query Parameters: None required
Status Codes:
200 OK - Content returned successfully401 Unauthorized - Authentication failed403 Forbidden - Insufficient permissions404 Not Found - Endpoint not found500 Internal Server Error - Server errorContent-Type: application/json
Body: ContentResponse object (see Section 3)
Example:
{ "status": "updated", "content": { "content_id": "clock-12:00:00", "frames": [ { "data_b64": "AQIDBAUGBwgJ", "width": 56, "height": 14, "duration_ms": 1000 } ], "playback": { "loop": false, "priority": 0, "interruptible": true } }, "poll_interval_ms": 30000 }
poll_interval_ms based on content update frequencyEndpoint: POST http://{driver_host}:{push_port}/
Purpose: Server pushes high-priority content directly to driver
Note: This endpoint is implemented by the driver, not the server. The server acts as a client to push urgent content.
Method: POST
Headers:
Content-Type: application/json
Authentication Headers (must match driver config):
{header_name}: {api_key}
OR
Authorization: Bearer {token}
Body: Content object (see Section 3.3)
Example:
{ "content_id": "urgent-alert", "frames": [ { "data_b64": "AQIDBAUGBwgJ", "width": 56, "height": 14, "duration_ms": 5000 } ], "playback": { "priority": 99, "interruptible": false } }
Status Codes:
200 OK - Content accepted401 Unauthorized - Authentication failed413 Payload Too Large - Request exceeds max_request_size400 Bad Request - Invalid JSON or validation error500 Internal Server Error - Driver errorBody:
{ "status": "accepted" }
Endpoint: GET http://{driver_host}:{push_port}/health
Response:
{ "status": "ok" }
The top-level response from the polling endpoint.
interface ContentResponse {
status: "updated" | "no_change" | "clear";
content?: Content;
poll_interval_ms: number;
}
Fields:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
status | enum | Yes | See below | Response status code |
content | Content | Conditional | Required if status="updated" | Content to display |
poll_interval_ms | integer | Yes | >= 1000 | Milliseconds until next poll |
Status Values:
| Status | Meaning | Content Field |
|---|---|---|
updated | New content available | Required |
no_change | Keep displaying current content | Omit |
clear | Clear the display | Omit |
Validation Rules:
status is "updated", content MUST be presentstatus is "no_change" or "clear", content MUST be absentpoll_interval_ms MUST be at least 1000 (1 second)A sequence of frames with playback configuration.
interface Content {
content_id: string;
frames: Frame[];
playback?: PlaybackMode;
metadata?: object;
}
Fields:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
content_id | string | Yes | Non-empty | Unique identifier for this content |
frames | Frame[] | Yes | 1-1000 frames | Array of frames to display |
playback | PlaybackMode | No | See below | Playback configuration |
metadata | object | No | Max 10KB JSON | Optional metadata for debugging |
Validation Rules:
frames MUST contain at least 1 frameframes MUST contain at most 1000 frameswidth and heightmetadata is present, JSON-encoded size MUST NOT exceed 10KBA single image to display.
interface Frame {
data_b64: string;
width: number;
height: number;
duration_ms?: number | null;
metadata?: object;
}
Fields:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
data_b64 | string | Yes | Valid base64 | Base64-encoded packed bit data |
width | integer | Yes | > 0 | Frame width in pixels |
height | integer | Yes | > 0 | Frame height in pixels |
duration_ms | integer/null | No | >= 0 or null | Display duration (null = indefinite) |
metadata | object | No | Max 10KB JSON | Optional metadata |
Validation Rules:
data_b64 MUST be valid base64 encodingceil(width * height / 8) byteswidth and height MUST match display dimensions (validated by driver)duration_ms of null or 0 means display indefinitelymetadata is present, JSON-encoded size MUST NOT exceed 10KBConfiguration for how content should be played.
interface PlaybackMode {
loop?: boolean;
loop_count?: number | null;
priority?: number;
interruptible?: boolean;
}
Fields:
| Field | Type | Required | Default | Constraints | Description |
|---|---|---|---|---|---|
loop | boolean | No | false | - | Whether to loop frames |
loop_count | integer/null | No | null | >= 1, requires loop=true | Number of loops (null = infinite) |
priority | integer | No | 0 | 0-99 | Priority level |
interruptible | boolean | No | true | - | Can be interrupted by higher priority |
Priority Levels:
Validation Rules:
loop_count can only be set if loop is truepriority MUST be between 0 and 99 inclusiveFrames use a packed binary format for efficiency:
Given a frame of width × height pixels:
ceil(width × height / 8)Bit Position Formula:
byte_index = pixel_index / 8
bit_position = pixel_index % 8
bit_value = (byte[byte_index] >> bit_position) & 1
Display: 3×2 pixels
Visual: Pixel Array: Bit Array:
1 0 1 [1, 0, 1, [1, 0, 1, 0, 1, 1, 0, 0]
0 1 1 0, 1, 1]
Byte 0: 0b00110101 = 0x35
Base64: "NQ=="
Python Generation:
import base64 def pack_bits_little_endian(bits): """Pack array of bits into bytes (little-endian).""" byte_array = bytearray((len(bits) + 7) // 8) for i, bit in enumerate(bits): if bit: byte_array[i // 8] |= 1 << (i % 8) return bytes(byte_array) bits = [1, 0, 1, 0, 1, 1, 0, 0] packed = pack_bits_little_endian(bits) b64 = base64.b64encode(packed).decode() # Result: "NQ=="
The server MUST implement one of the following authentication methods:
Authorization: Bearer {token}
401 Unauthorized if invalidauth.token{header_name}: {api_key}
X-API-Keyauth.header_name401 Unauthorized if invalidauth.keyThe server SHOULD respect these limits to prevent resource exhaustion:
| Limit | Value | Purpose |
|---|---|---|
| Max frames per content | 1000 | Prevent memory exhaustion |
| Max total bytes | 5 MB | Prevent memory exhaustion |
| Max metadata per item | 10 KB | Prevent metadata abuse |
| Max push request size | 10 MB | Prevent network abuse |
Note: These limits are enforced by the driver's validation. Content exceeding limits will be rejected.
The server MAY implement rate limiting. Recommended approach:
429 Too Many Requests if rate limit exceededRetry-After header with seconds to waitThe server SHOULD use HTTPS in production to protect:
Driver Side:
poll_interval_mslast_poll_time at start of request (even if it fails)Server Side:
poll_interval_ms based on expected update frequencyno_change to reduce bandwidthNew Content:
{ "status": "updated", "content": { "content_id": "new-123", ... } }
No Change:
{ "status": "no_change", "poll_interval_ms": 30000 }
Clear Display:
{ "status": "clear", "poll_interval_ms": 10000 }
If the server returns content with the same content_id as currently playing content:
Priority Queue (Driver Side):
Server Recommendations:
interruptible: false for critical messagesDuration Behavior:
duration_ms > 0: Frame displays for exactly this durationduration_ms = 0 or null: Frame displays indefinitelyLoop Behavior:
loop: false: Play frames once, then completeloop: true, loop_count: null: Loop indefinitelyloop: true, loop_count: N: Loop N times, then complete| Status Code | Driver Behavior |
|---|---|
| 401/403 | Log auth error, apply backoff |
| 404 | Log endpoint error, apply backoff |
| 429 | Apply backoff (respect Retry-After) |
| 500+ | Log server error, apply backoff |
| Timeout | Apply backoff |
| Network error | Apply backoff |
If driver receives invalid data:
error_fallback: keep_last)Configured by driver's error_fallback setting:
| Mode | Behavior |
|---|---|
keep_last | Keep displaying last successful content |
blank | Clear display on error |
error_message | Show error state (future) |
Minimal Response:
{ "status": "no_change", "poll_interval_ms": 30000 }
Static Image:
{ "status": "updated", "content": { "content_id": "hello-world", "frames": [{ "data_b64": "AQIDBAUGBwgJ", "width": 56, "height": 14, "duration_ms": null }] }, "poll_interval_ms": 60000 }
Animation:
{ "status": "updated", "content": { "content_id": "loading-spinner", "frames": [ { "data_b64": "AQIDBA==", "width": 8, "height": 8, "duration_ms": 100 }, { "data_b64": "BQYHCA==", "width": 8, "height": 8, "duration_ms": 100 } ], "playback": { "loop": true } }, "poll_interval_ms": 30000 }
High-Priority Notification:
{ "status": "updated", "content": { "content_id": "doorbell-ring", "frames": [{ "data_b64": "ISEhISE=", "width": 56, "height": 14, "duration_ms": 5000 }], "playback": { "priority": 10, "interruptible": false } }, "poll_interval_ms": 30000 }
See openapi.yaml for machine-readable API specification.
Current Version: 2.0
Version History:
Breaking Changes in 2.0:
ContentResponse = %s"{" *WSP %s"\"status\"" *WSP ":" *WSP Status *WSP "," [ %s"\"content\"" *WSP ":" *WSP Content *WSP "," ] %s"\"poll_interval_ms\"" *WSP ":" *WSP Integer *WSP %s"}" Status = %s"\"updated\"" / %s"\"no_change\"" / %s"\"clear\"" Content = %s"{" *WSP %s"\"content_id\"" *WSP ":" *WSP String *WSP "," %s"\"frames\"" *WSP ":" *WSP "[" Frame *( "," Frame ) "]" *WSP [ "," *WSP %s"\"playback\"" *WSP ":" *WSP PlaybackMode ] [ "," *WSP %s"\"metadata\"" *WSP ":" *WSP Object ] *WSP %s"}" Frame = %s"{" *WSP %s"\"data_b64\"" *WSP ":" *WSP String *WSP "," %s"\"width\"" *WSP ":" *WSP Integer *WSP "," %s"\"height\"" *WSP ":" *WSP Integer [ "," *WSP %s"\"duration_ms\"" *WSP ":" *WSP ( Integer / Null ) ] [ "," *WSP %s"\"metadata\"" *WSP ":" *WSP Object ] *WSP %s"}" PlaybackMode = %s"{" *WSP [ %s"\"loop\"" *WSP ":" *WSP Boolean ] [ "," *WSP %s"\"loop_count\"" *WSP ":" *WSP ( Integer / Null ) ] [ "," *WSP %s"\"priority\"" *WSP ":" *WSP Integer ] [ "," *WSP %s"\"interruptible\"" *WSP ":" *WSP Boolean ] *WSP %s"}"
bytes_needed = ceil(width × height / 8)
bit_index = (row × width) + col
byte_index = bit_index / 8
bit_position = bit_index % 8
bit_value = (byte[byte_index] >> bit_position) & 1
Document Status: Ready for Implementation Feedback: Please open issues at the project repository