Inklet SDK v0.1¶
/api/sdk/v1 是服务端 JS/TS SDK 对接的接口面。它是一层增量 facade:底层读取的仍是 Portal、iOS App 和设备共用的同一批数据,只是用三个自己的名词重新组织。
| SDK 名词 | 含义 |
|---|---|
| Display | 你拥有的一块屏 |
| Content | 一次提交:文本、链接、图片、文件,或它们的组合 |
| Presentation | 一份不可变的渲染结果:投向某一块 Display(面向 Display),或独立生成的 Scene + PNG 渲染图(targetless) |
Targetless Presentation(无设备呈现)
在 v0.1 中,POST /contents 支持可选的 output 字段。加入此字段即可在不绑定任何显示屏的情况下生成可视化内容(inklet Scene v1 + PNG rendition)。详见 Targetless Presentations。
Base URL
https://dev.iminklet.com/api/sdk/v1
一次推送请求只创建一个 Content,而 Content 不是一个显示任务。 它可能产出多个 Presentation——每到达一块 Display 就有一个——每个一经创建即不可变。
快速开始¶
五步把一张图推到指定的 Display。
第 1 步 —— 获取 Personal Access Token¶
在 /api/personal-access-tokens 创建(参见 个人访问令牌),形如 il_pat_...。
第 2 步 —— 列出你的 Display¶
const BASE = "https://dev.iminklet.com/api/sdk/v1";
const PAT = "il_pat_...";
const res = await fetch(`${BASE}/displays`, {
headers: { Authorization: `Bearer ${PAT}` },
});
const { items } = await res.json();
const displayId = items[0].id; // pick a Display
第 3 步 —— 创建 Content 并获取上传票据¶
Hardcode 模式:一张 PNG/JPEG 直接推到某块 Display。
const { v4: uuidv4 } = require("uuid");
const create = await fetch(`${BASE}/contents`, {
method: "POST",
headers: {
Authorization: `Bearer ${PAT}`,
"Content-Type": "application/json",
"Idempotency-Key": uuidv4(), // 8–128 printable ASCII
},
body: JSON.stringify({
mode: "hardcode",
displayId,
assets: [{
type: "image",
filename: "panel.png",
contentType: "image/png",
sizeBytes: 204800,
}],
}),
});
const { content, uploadTickets } = await create.json();
// content.id → your Content's UUID
// uploadTickets[0] → presigned S3 POST for assets[0]
第 4 步 —— 把二进制 asset 上传到 S3¶
切勿把 PAT 发往预签名地址
只使用票据里的 fields,不要附加 Authorization 头。
const ticket = uploadTickets[0]; // one per binary asset
const form = new FormData();
for (const [k, v] of Object.entries(ticket.fields)) {
form.append(k, v as string);
}
form.append("file", fs.createReadStream("./panel.png")); // must be last
await fetch(ticket.url, { method: "POST", body: form }); // no Auth header
票据在 ticket.expiresAt 过期。若已过期,调用 POST /contents/{contentId}/upload-tickets 重新签发。
第 5 步 —— 确认上传并轮询直到 ready¶
// Trigger processing
const confirmRes = await fetch(
`${BASE}/contents/${content.id}/confirm`,
{
method: "POST",
headers: { Authorization: `Bearer ${PAT}` },
}
);
let current = await confirmRes.json();
// Poll until the Content leaves processing
while (current.state === "processing") {
await new Promise(r => setTimeout(r, 2000));
const poll = await fetch(`${BASE}/contents/${current.id}`, {
headers: { Authorization: `Bearer ${PAT}` },
});
current = await poll.json();
}
if (current.state === "ready") {
console.log("Presentation IDs:", current.presentationIds);
} else {
console.error("Failed:", current.processing.error);
}
state 到达 ready 时 presentationIds 即已填充。屏幕会按自己的节奏取图并确认——ready 表示后端已把渲染排上队,不代表屏幕已经显示出来了。
认证¶
只接受用户级 Personal Access Token:
有两点与后端其余部分不同,且是刻意为之:
- Session JWT 在这里会被拒绝。 它在别处都能认证,但在这个前缀下一律
401。 - 永不下发
X-Renewed-Token。 滑动续签不适用于 PAT。
所有失败态——缺 header、scheme 错误、传了 JWT、未知 / 过期 / 已撤销的 PAT——都返回完全相同的 401 authentication_failed,因此无法用响应差异去探测某个 token 是否存在。
PAT 的生命周期管理(POST / DELETE /api/personal-access-tokens)不在本 facade 内。
通用约定¶
- 请求与响应均为 JSON;UUID 用字符串;时间戳为 RFC3339 UTC。
- 可选值显式为
null;稳定数组为[],不会是null。 - 未知的请求字段会被忽略;未知的枚举值返回
400 invalid_request。
请求 ID¶
每个响应都带 X-Request-Id,每个错误 body 的 requestId 里也会复述同一个值。报障时请附上它。
该 ID 由服务端生成。客户端传入的 X-Request-Id 会被忽略而不是回显:回显等于开放了 header 注入的口子。请用响应里返回的那个 ID。
错误信封¶
{
"error": {
"code": "invalid_request",
"message": "Safe developer-facing message.",
"requestId": "req_2f8c1d0a4b6e8f0a2c4d6e80",
"details": {}
}
}
请依据 code 分支处理。message 的措辞不属于契约,可能变动。details 为空时会被省略。
幂等¶
POST /contents 必须携带 Idempotency-Key 请求头,8–128 个可打印 ASCII 字符。
- 作用域为 用户 + method + 路由;请求 body 用 SHA-256 计算指纹。
- 结果保留 24 小时。
- 窗口内同 key 同 body → 原样重放原始响应,包括原始的上传票据。副作用不会执行第二次。
- 同 key 不同 body →
409 idempotency_conflict。 - 首个请求仍在进行中时同 key 再次到达 →
409 invalid_state(结果尚不可重放)。 - 超过 24 小时后,该 key 视同从未出现过。
请求被拒绝时会释放该 key,因此你可以改好 body 后用同一个 key 重试。
重放不会创建新的 Content 或新的处理任务。若重放拿到的票据已过期,请通过 POST /contents/{contentId}/upload-tickets 重新签发。
分页¶
基于 cursor,按 (createdAt, id) 降序,顺序确定。limit 默认 20,最大 50。
非法的 cursor 或超范围的 limit 返回 400 invalid_request——而不是静默退回第一页。请把 cursor 当作不透明字符串,原样回传。
Display¶
GET /displays¶
分页列出你拥有的 Display。
空列表与后端故障是两种不同的答案:一块屏都没有时返回 200 {"items": []},故障时返回 500。客户端绝不应因为后端不可达就渲染出一个空面板。
GET /displays/{displayId}¶
- Display 存在但不属于你 →
403 access_denied - 不存在 →
404 display_not_found displayId不是 UUID →400 invalid_request
Display 模型¶
{
"id": "019fd0cc-4d20-702e-a7c6-baae19b70d25",
"hardwareId": "hw-abc123",
"thingName": "inklet-abc123",
"name": "Kitchen",
"nickname": "Kitchen",
"firmware": "1.4.2",
"batteryPercent": 82,
"online": true,
"lastSeenAt": "2026-08-12T07:21:17Z",
"stateUpdatedAt": "2026-08-12T07:20:55Z",
"boundAt": "2026-06-01T09:12:00Z",
"tags": ["kitchen"],
"syncIntervalMinutes": null,
"nextSyncAt": null,
"currentPresentationId": "019fd0aa-...",
"currentPresentationUpdatedAt": "2026-08-12T06:02:11Z",
"pendingPresentationId": "019fd0bb-...",
"capabilities": {
"pixelWidth": 800,
"pixelHeight": 480,
"orientation": "landscape",
"colorMode": "mono",
"supportedImageContentTypes": ["image/png", "image/jpeg"],
"supportedOutputFormats": ["png", "raw2", "raw4"]
}
}
未设置昵称时 name 回退为 thingName——始终非空。
v0.1 中 syncIntervalMinutes 与 nextSyncAt 恒为 null
设备按固件侧的节奏轮询,后端既不存储也不控制这个周期。这两个字段先行存在,是为了将来接入数据源时不构成破坏性变更。请不要依据它们做任何调度。
Capabilities¶
v0.1 中所有 Display 都返回同一份固定画像:800×480、横向(landscape)、单色(mono)。capabilities 由统一的 provider 接口层解析,将来改为按 SKU 区分时这段结构也不会变。请始终从 Display 读取 capabilities,不要硬编码尺寸。
current 与 pending¶
这是两个彼此独立的问题:
| 字段 | 含义 |
|---|---|
currentPresentationId |
屏幕已确认正在显示的那一个——玻璃上真实呈现的内容 |
pendingPresentationId |
已发布给这块屏、但尚未被确认的那一个。确认后变为 null |
"current" 绝不等于"我们最后发出去的那份"。未确认的 Presentation 可能从未被取走——屏幕当时也许在休眠。离线的 Display 会保留这两个引用、capabilities 和最后已知状态。
Content¶
POST /contents¶
必须携带 Idempotency-Key。创建 Content,并为每个二进制 asset 返回一张预签名 S3 POST 票据。文本与链接 asset 内联携带内容,不产生票据。
const res = await fetch(`${BASE}/contents`, {
method: "POST",
headers: {
Authorization: `Bearer ${PAT}`,
"Content-Type": "application/json",
"Idempotency-Key": uuidv4(),
},
body: JSON.stringify({
mode: "auto",
assets: [
{ type: "text", text: "Morning briefing" },
{
type: "image",
filename: "chart.png",
contentType: "image/png",
sizeBytes: 102400,
},
],
}),
});
const { content, uploadTickets } = await res.json();
const res = await fetch(`${BASE}/contents`, {
method: "POST",
headers: {
Authorization: `Bearer ${PAT}`,
"Content-Type": "application/json",
"Idempotency-Key": uuidv4(),
},
body: JSON.stringify({
mode: "manual",
displayId: "019fd0cc-...",
assets: [
{ type: "link", url: "https://example.com/dashboard" },
],
}),
});
const res = await fetch(`${BASE}/contents`, {
method: "POST",
headers: {
Authorization: `Bearer ${PAT}`,
"Content-Type": "application/json",
"Idempotency-Key": uuidv4(),
},
body: JSON.stringify({
mode: "hardcode",
displayId: "019fd0cc-...",
assets: [{
type: "image",
filename: "panel.png",
contentType: "image/png",
sizeBytes: 204800,
}],
}),
});
const res = await fetch(`${BASE}/contents`, {
method: "POST",
headers: {
Authorization: `Bearer ${PAT}`,
"Content-Type": "application/json",
"Idempotency-Key": uuidv4(),
},
body: JSON.stringify({
mode: "auto",
assets: [{
type: "file",
filename: "report.pdf",
contentType: "application/pdf",
sizeBytes: 512000,
}],
}),
});
curl -X POST "$BASE/contents" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"mode": "hardcode",
"displayId": "019fd0cc-...",
"assets": [{
"type": "image",
"filename": "panel.png",
"contentType": "image/png",
"sizeBytes": 204800
}]
}'
各 mode 的规则¶
| Mode | displayId |
output |
Asset |
|---|---|---|---|
auto(面向 Display) |
不得指定 | 省略 | 一个或多个,类型不限 |
auto(targetless) |
禁止(null 或省略) | 必须有 | 一个或多个,类型不限 |
manual |
必填,且必须是你可访问的 | 不得有 | 一个或多个,类型不限 |
hardcode(面向 Display) |
必填,且必须是你可访问的 | 省略 | 恰好一张 PNG 或 JPEG |
hardcode(targetless) |
禁止(null 或省略) | 必须有 | 恰好一张 PNG 或 JPEG |
output 字段的存在是 targetless 分流的唯一依据。详见 Targetless Presentations。
响应 201:
{
"content": { /* Content object */ },
"uploadTickets": [
{
"assetIndex": 1,
"url": "https://s3.amazonaws.com/...",
"fields": {
"key": "uploads/...",
"AWSAccessKeyId": "...",
"policy": "...",
"signature": "...",
"x-amz-security-token": "..."
},
"expiresAt": "2026-08-12T14:00:00Z"
}
]
}
assetIndex 是你在 assets 数组里的原始索引——它会贯穿票据、失败报告和落库的 Content,所以"索引 2 失败"指的就是你提交的第 3 个 asset。
Asset 类型¶
| 类型 | 必填字段 | 允许的取值 |
|---|---|---|
text |
text(不能是纯空白) |
— |
link |
url(绝对 HTTP/S,不含凭据) |
— |
image |
filename、contentType、sizeBytes |
image/png、image/jpeg、image/gif、image/webp、image/svg+xml |
file |
filename、contentType、sizeBytes |
application/pdf、text/plain、text/markdown、application/json |
单个二进制 asset 上限 10 MiB(超出为 413 asset_too_large),单次请求最多 50 个 asset。无法识别的 contentType 返回 400 invalid_asset;未知的 type 返回 400 invalid_request。声明的 type 必须与 contentType 一致——把 PDF 声明为 image 会被拒绝。
把二进制 asset 上传到 S3¶
async function uploadAsset(ticket: UploadTicket, filePath: string) {
const form = new FormData();
// Append all presigned fields FIRST
for (const [k, v] of Object.entries(ticket.fields)) {
form.append(k, v);
}
// File must be last
form.append("file", fs.createReadStream(filePath), {
filename: path.basename(filePath),
});
const res = await fetch(ticket.url, {
method: "POST",
body: form,
// NO Authorization header — the ticket is the credential
});
if (!res.ok) throw new Error(`S3 upload failed: ${res.status}`);
}
若票据在上传完成前过期,请用 POST /contents/{contentId}/upload-tickets 重新签发(见下)——重放 POST /contents 拿回的仍是那张原始的、已过期的票据。
POST /contents/{contentId}/confirm¶
校验每一个二进制 asset(HeadObject),全部到位则恰好投递一个处理任务。
| 结果 | state |
upload.status |
|---|---|---|
| 全部 asset 到位 | processing |
complete |
| 部分 asset 缺失 | pending |
partial——见 upload.failedAssetIndexes |
| 已确认过 | 保持不变 | ——返回已有 Content,不产生重复任务 |
confirm 本身就是可安全重试的,无需 Idempotency-Key。并发的 confirm 通过数据库原子 claim 保证只投递一个任务。
POST /contents/{contentId}/upload-tickets¶
为失败或票据已过期的二进制 asset 重新签发票据。
const res = await fetch(
`${BASE}/contents/${contentId}/upload-tickets`,
{
method: "POST",
headers: {
Authorization: `Bearer ${PAT}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ assetIndexes: [2] }), // your original indexes
}
);
const { content, uploadTickets } = await res.json();
- 文本与链接索引返回
400 invalid_request——它们没有上传这一步。 - 已经开始处理的 Content 返回
409 invalid_state。
GET /contents/{contentId}¶
const res = await fetch(`${BASE}/contents/${contentId}`, {
headers: { Authorization: `Bearer ${PAT}` },
});
const content = await res.json();
不属于你的 Content 返回 404 content_not_found。与 Display 不同,Content 没有 403——Content ID 不可被探测。
GET /contents¶
const res = await fetch(
`${BASE}/contents?state=ready&mode=hardcode&limit=10`,
{ headers: { Authorization: `Bearer ${PAT}` } }
);
const { items, nextCursor, hasMore } = await res.json();
过滤条件:state(pending、processing、ready、failed)、mode(auto、manual、hardcode)。任一项传入未知值都返回 400 invalid_request,而不是一个空页——拼错应该被指出来,而不是被悄悄吞掉。
Content 模型¶
{
"id": "019fd100-...",
"mode": "hardcode",
"requestedDisplayId": "019fd0cc-...",
"intent": null,
"title": null,
"state": "ready",
"output": null,
"assets": [
{
"assetIndex": 0,
"type": "image",
"text": null,
"url": null,
"filename": "panel.png",
"contentType": "image/png",
"sizeBytes": 204800,
"uploadState": "uploaded"
}
],
"upload": {
"status": "complete",
"failedAssetIndexes": []
},
"processing": {
"stage": "complete",
"warnings": [],
"error": null
},
"presentationIds": ["019fd0aa-..."],
"createdAt": "2026-08-12T12:00:00Z",
"updatedAt": "2026-08-12T12:01:30Z"
}
output 字段:面向 Display 的 Content 为 null;targetless Content 为归一化的 output profile(含 formats、preset、viewport、colorMode)。
文本与链接 asset 生来就是 uploadState: uploaded;只有二进制 asset 会经历 pending → uploaded(或 failed)。
Content 状态¶
pending → processing → ready,或 → failed。
processing.stage 反映进度:awaiting_upload、fetching_links、summarizing、routing、creating_presentations、complete、failed。
ready 不代表屏上已经出现了东西
Content 到达 ready 只意味着后端已把最终的 Presentation ID 落库。屏幕是否已取走并确认某一个,是另一个问题——玻璃上真实呈现的内容请读 Display 的 currentPresentationId。
processing.error 带有稳定的 code、安全的 message、失败所处的 stage、是否 retryable,以及可为 null 的 assetIndex。
处理模式¶
Auto¶
分析阶段只针对这一个 Content 运行,挑选模板与参数,并选出一块或多块可访问且兼容的 Display。后端为每块选中的 Display 创建一个不可变的 Presentation。若找不到兼容的目标,Content 以 no_compatible_display 失败——绑定一块屏后重新提交即可。
auto 不得设置 displayId。分析阶段可以通过回调收窄 Display 集合,但每个候选都会再对照你的归属关系做校验。
Manual¶
流程相同,但锁定到你在创建时指定的那块 Display。指定的 Display 绝不会被替换。 它会在路由阶段从你的提交中重新读取并重新鉴权,因此下游任何环节都无法改变渲染落点。若该屏无法显示该内容,Content 以 display_incompatible 失败。
Hardcode¶
一张图、一块屏,不经过任何 AI。
Hardcode 的缩放规则 —— 各向异性拉伸
图片会用 Pillow Image.LANCZOS 直接缩放到 800×480:
- 不做 letterbox、不裁剪、不保持宽高比。
- 一张 8:1 的全景条会被压扁以铺满整屏。
- 一张 1:1 的正方形会被拉成 5:3。
- 输入尺寸永远不是拒绝的理由。 有什么图就传什么图;在意变形的话请自行先裁成 5:3。
彩色 PNG 预览会保留。设备收到的仍是与旧路径完全一致的 Floyd–Steinberg 抖动 raw 产物。
Hardcode 不跑 Summary、不跑 Analyze、不调 LLM、不经过模板阶段,也绝不使用全设备推送路径。
状态轮询¶
async function waitForReady(
contentId: string,
pat: string,
intervalMs = 2000,
maxAttempts = 60
): Promise<Content> {
for (let i = 0; i < maxAttempts; i++) {
const res = await fetch(`${BASE}/contents/${contentId}`, {
headers: { Authorization: `Bearer ${pat}` },
});
const content: Content = await res.json();
if (content.state === "ready" || content.state === "failed") {
return content;
}
await new Promise(r => setTimeout(r, intervalMs));
}
throw new Error("Timed out waiting for Content to reach ready");
}
Presentation¶
Presentation 通过 SDK 是不可变的。v0.1 没有提供任何 create、update、delete、reorder、publish、replay、skip 或 expire 接口。
v0.1 有两类 Presentation:
- 面向 Display:
displayId为 UUID,包含image字段,对应 Display 的渲染队列与 MQTT 推送链路。 - Targetless(无设备):
displayId为null,包含scene(inklet Scene v1)和renditions(PNG 渲染图列表),不涉及 Display 路由。详见 Targetless Presentations。
GET /presentations/{presentationId}¶
面向 Display 的 Presentation 响应示例:
{
"id": "019fd0aa-...",
"displayId": "019fd0cc-...",
"contentIds": ["019fd100-..."],
"mode": "hardcode",
"state": "confirmed",
"scene": null,
"renditions": [],
"image": {
"url": "https://cdn.iminklet.com/render/.../image.png?...",
"format": "png",
"width": 800,
"height": 480,
"expiresAt": "2026-08-12T13:15:00Z",
"updatedAt": "2026-08-12T12:58:11Z"
},
"failure": null,
"createdAt": "2026-08-12T12:01:00Z",
"updatedAt": "2026-08-12T12:05:00Z"
}
contentIds 是有序的,且该顺序在创建时即已固定。
面向 Display 的 state:preparing → queued → published → confirmed,或 expired / failed。
?format=png|raw2|raw4 选择产物(仅面向 Display 的 Presentation 支持)。
仍在渲染中的 Presentation 有 state,但没有 image 块——而不是一个 URL 为空的 image。
不属于你的 Presentation 返回 404 presentation_not_found。
GET /presentations¶
scope 可选值:generated(targetless,默认)/ display / all。返回标准 cursor 分页信封。
GET /displays/{displayId}/queue¶
基于 cursor 分页,可选 from / to(RFC3339)过滤。只返回排队中的条目——preparing 还在渲染,published 已经发出。
- Display 不属于你 →
403 access_denied - Display 不存在 →
404 display_not_found
队列条目是摘要对象,无渲染元数据:
{
"items": [
{
"id": "019fd0aa-...",
"displayId": "019fd0cc-...",
"contentIds": ["019fd100-..."],
"mode": "hardcode",
"state": "queued",
"createdAt": "2026-08-12T12:01:00Z",
"updatedAt": "2026-08-12T12:01:00Z"
}
],
"nextCursor": null,
"hasMore": false
}
GET /displays/{displayId}/current-presentation¶
返回 Display 已确认正在显示的 Presentation——绝不是最新发出的那个 URL。
- Display 不属于你 →
403 access_denied - Display 不存在 →
404 display_not_found
?format=png|raw2|raw4 选择 image.url 所指向的产物(默认 png)。
或者返回完整的 Presentation 对象。presentation 这层信封始终存在,因此"当前没有内容"是一个可以直接判断的结构,而不是缺失的 body。
离线的 Display 继续返回其最后一次确认的 Presentation——那仍然是屏幕上的实际内容。
预览 URL¶
签名 URL 有效期约 15 分钟。重新读取 Presentation 会对同一份已存储的图片重新签名——绝不会重新渲染。过期后再取一次新的即可;请不要缓存超过 expiresAt 的 URL。
读操作不会改变任何状态¶
/api/sdk/v1 中没有任何接口会提升队列条目、向屏幕发布、重新渲染、唤醒休眠的 Display 或修改同步周期。你不可能因为轮询一个面板就把客户的屏幕唤醒。
会产生写入的旧接口(GET /api/devices/{id}/push——它确实会提升状态——以及 POST /api/devices/{id}/current-push)在这个前缀下不可达。
错误码速查¶
| 状态码 | Code |
|---|---|
| 400 | invalid_request |
| 400 | invalid_asset |
| 401 | authentication_failed |
| 402 | payment_required(订阅扣款失败) |
| 403 | access_denied |
| 403 | plan_upgrade_required(Free 用户请求 Pro 功能) |
| 404 | display_not_found |
| 404 | content_not_found |
| 404 | presentation_not_found |
| 409 | idempotency_conflict |
| 409 | invalid_state |
| 413 | asset_too_large |
| 422 | display_incompatible |
| 422 | no_compatible_display |
| 429 | rate_limited(附 Retry-After 响应头) |
| 500 | internal_error |
| 503 | processing_unavailable |
image_dimensions_mismatch 不存在。Hardcode 的产品决策已将其移除——任何输入尺寸都会被接受。
安全¶
| 规则 | 说明 |
|---|---|
| PAT 是 Bearer token | 只接受 Authorization: Bearer il_pat_... |
| 不要把 PAT 发给 S3 | 只使用预签名的 fields——绝不向预签名地址发送 Authorization |
| 不接受 JWT | Session JWT 在这个前缀下一律 401 |
无 X-Renewed-Token |
Session 中间件的滑动续签不适用于 PAT |
| 不接受调用方传入的请求 ID | 传入的 X-Request-Id 会被忽略;请用响应里返回的那个 |
| 500 的错误信息是通用的 | 内部错误文本(表名、约束名)绝不外泄 |
重试与幂等一览¶
| 操作 | 重试机制 |
|---|---|
POST /contents |
Idempotency-Key(必填)——同 key 同 body 原样重放原始响应 |
POST /contents/{id}/confirm |
天然可安全重试——原子 claim 防止重复任务 |
POST /contents/{id}/upload-tickets |
本身幂等——每次调用返回新票据 |
GET 类接口 |
永远可安全重试 |
最小 TypeScript 类型定义¶
// ----- Enums -----
type ContentMode = "auto" | "manual" | "hardcode";
type ContentState = "pending" | "processing" | "ready" | "failed";
type ProcessStage =
| "awaiting_upload" | "fetching_links" | "summarizing"
| "routing" | "creating_presentations" | "complete" | "failed";
type AssetType = "text" | "link" | "image" | "file";
type UploadState = "pending" | "uploaded" | "failed";
type UploadStatus = "awaiting_upload" | "partial" | "complete";
type PresState =
| "preparing" | "queued" | "published" | "confirmed" | "expired" | "failed";
// ----- Assets -----
interface AssetInput {
type: AssetType;
text?: string | null;
url?: string | null;
filename?: string | null;
contentType?: string | null;
sizeBytes?: number | null;
}
interface Asset extends AssetInput {
assetIndex: number;
uploadState: UploadState;
}
// ----- Upload ticket -----
interface UploadTicket {
assetIndex: number;
url: string;
fields: Record<string, string>;
expiresAt: string;
}
// ----- Problem (warning / error) -----
interface Problem {
code: string;
message: string;
stage: string | null;
retryable: boolean;
assetIndex: number | null;
}
// ----- Content -----
interface Content {
id: string;
mode: ContentMode;
requestedDisplayId: string | null;
intent: string | null;
title: string | null;
state: ContentState;
assets: Asset[];
upload: {
status: UploadStatus;
failedAssetIndexes: number[];
};
processing: {
stage: ProcessStage | null;
warnings: Problem[];
error: Problem | null;
};
presentationIds: string[];
createdAt: string;
updatedAt: string;
}
// ----- Capabilities -----
interface Capabilities {
pixelWidth: 800;
pixelHeight: 480;
orientation: "landscape";
colorMode: "mono";
supportedImageContentTypes: string[];
supportedOutputFormats: string[];
}
// ----- Display -----
interface Display {
id: string;
hardwareId: string;
thingName: string;
name: string;
nickname: string | null;
firmware: string | null;
batteryPercent: number | null;
online: boolean;
lastSeenAt: string | null;
stateUpdatedAt: string | null;
boundAt: string | null;
tags: string[];
syncIntervalMinutes: null;
nextSyncAt: null;
currentPresentationId: string | null;
currentPresentationUpdatedAt: string | null;
pendingPresentationId: string | null;
capabilities: Capabilities;
}
// ----- Presentation -----
interface PresentationImage {
url: string;
format: string;
width: number;
height: number;
expiresAt: string;
updatedAt: string;
}
interface Presentation {
id: string;
displayId: string;
contentIds: string[];
mode: ContentMode;
state: PresState;
image: PresentationImage | null;
failure: Problem | null;
createdAt: string;
updatedAt: string;
}
// ----- API responses -----
interface PagedResponse<T> {
items: T[];
nextCursor: string | null;
hasMore: boolean;
}
interface CreateContentResponse {
content: Content;
uploadTickets: UploadTicket[];
}
interface ApiError {
error: {
code: string;
message: string;
requestId: string;
details?: Record<string, unknown>;
};
}
SDK 实现自检清单¶
用这份清单核对手写或生成的 SDK 是否覆盖了完整契约。其中没有任何一项依赖旧的 /api/* 路由。
认证¶
- [ ] 每个请求都带
Authorization: Bearer il_pat_... - [ ] 拒绝 / 永不发送 Session JWT
- [ ] 绝不把 PAT 发往预签名 S3 地址
- [ ] 从每个响应中暴露
X-Request-Id,供报障使用
Display¶
- [ ]
GET /displays支持 cursor + limit 分页 - [ ]
GET /displays/{id}—— 区分处理403 access_denied与404 - [ ] 从 Display 读取
capabilities,不要硬编码 800×480
Content¶
- [ ] 每次逻辑上的
POST /contents尝试生成唯一的Idempotency-Key - [ ] 支持
mode: auto | manual | hardcode及其各自的displayId规则 - [ ] 支持全部四种 asset 类型:text、link、image、file
- [ ] 用
ticket.fields把每个二进制 asset 上传到ticket.url——不带Authorization - [ ] 处理部分确认(
upload.failedAssetIndexes),只重试失败的索引 - [ ] 重传前先用
POST /contents/{id}/upload-tickets刷新过期票据 - [ ] confirm 之后轮询
GET /contents/{id}观察state变化 - [ ]
GET /contents支持state、mode、cursor、limit 过滤
处理¶
- [ ] 对
503 processing_unavailable实现重试逻辑 - [ ] 记录终态失败的
processing.error.code - [ ] 理解
ready≠ 屏幕已确认该 Presentation
Presentation¶
- [ ]
GET /presentations/{id}—— 处理渲染中时image: null的情况;非所属 Display 的 Presentation 返回404(非403) - [ ]
GET /displays/{id}/queue—— cursor 分页,只含 queued 条目;不属于你时返回403 - [ ]
GET /displays/{id}/current-presentation——format参数(默认png);处理{ "presentation": null };不属于你时返回403 - [ ]
image.expiresAt过期后刷新签名预览 URL(重读会重签名,不会重新渲染)
错误¶
- [ ] 依据
error.code分支,而不是error.message - [ ] 在日志与客服流程中暴露
error.requestId - [ ] 依据
Retry-After响应头处理429 rate_limited
v0.1 不在范围内的能力¶
Project、Service Key 与 Scope;配对、解绑、Wi-Fi、固件与同步周期的修改;Presentation 与队列的修改;调度与优先级;任意 HTML/CSS;Webhook;唤醒休眠的 Display;修改旧的 Portal / iOS / 设备接口。