
Trmnl Paper Screen
- 4 installs
- 4 repo stars
- Updated April 9, 2026
- miantiao-me/trmnl-paper
Helps with ai & agent building tasks during AI-assisted development.
About
trmnl-paper-screen is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- trmnl-paper-screen
- AI & Agent Building
- AI-coding skill
Trmnl Paper Screen by the numbers
- 4 all-time installs (skills.sh)
- Ranked #13,372 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/miantiao-me/trmnl-paper --skill trmnl-paper-screenAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 4 |
| Last updated | April 9, 2026 |
| Repository | miantiao-me/trmnl-paper ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
trmnl-paper-screen
概览
负责把已有 markup 通过 POST /api/screens 推送到 LaraPaper。实际发送成功后,再取回当前渲染图(路径见 references/api-screens.md),审查展示是否正常、是否适合 e-paper;若有问题,则把问题清单回传给 trmnl-paper-blade 做下一轮修正。
工作流
1. 确认 markup 是否就绪
- 没有 → 先用
trmnl-paper-blade生成,再回到这里 - 有 → 继续
2. 收集参数(四个都必须):
| 参数 | 说明 | 示例 |
|---|---|---|
base URL | LaraPaper 实例地址,必须带 scheme | https://larapaper.example.com |
mac_address | 设备 MAC,服务端会自动转大写 | AA:BB:CC:DD:EE:FF |
device API key | 设备级 API key(不是 APP_KEY) | abc123... |
| markup | 要推送的 Blade / HTML 内容 | <x-trmnl::screen>...</x-trmnl::screen> |
3. 默认 dry-run:预览完整请求体(凭据已脱敏),用户明确要求时才实际 POST;如需查看原始凭据,仅 dry-run 可加 --show-secrets。 4. 优先使用脚本:从仓库根目录运行 python3 skills/trmnl-paper-screen/scripts/push_screen.py ...,避免手写 JSON 转义错误;如果当前目录已在 skill 内,可用 python3 scripts/push_screen.py ...。 5. 实际发送后读取渲染结果:使用 --send --current-screen,或在发送成功后调用当前屏幕接口(路径见 references/api-screens.md)。 6. 需要视觉审查时下载图片:使用 --download-image <path> 把 image_url 下载到本地,再检查展示是否正常与美观;--download-image 会隐式触发 current_screen 读取。 7. 若有问题,走修正闭环:整理问题清单 → 交回 trmnl-paper-blade 改 markup → validate_markup.py 校验 → 重新推送并复查。
推送后审查闭环
只在用户明确要求实际发送时进入这个闭环;dry-run 不做图片审查。
1. POST /api/screens 推送 markup 2. 取回当前渲染图 image_url(路径见 references/api-screens.md) 3. 下载图片并做人工/多模态审查(审查标准见 references/review-checklist.md) 4. 按问题类型回到 trmnl-paper-blade 修正 5. 重新校验、推送、复查
默认先修最明显的 1-3 个问题,避免一次改太多导致回归。若两轮后仍存在明显取舍,再向用户说明并确认方向。
如果目标实例做了异步渲染或额外缓存,取回的图片可能短暂是上一版;此时等待片刻后重试即可,不要误判为新稿无效。
规则
- 不伪造
base URL、mac_address、device API key - 只使用
POST /api/screens - 取回渲染结果的路径见
references/api-screens.md - 请求头固定为
id+access-token - body 固定为
{"image":{"content":"...","file_name":"..."}} file_name可选,默认screen.blade.markup- 默认把 markup 与请求体分开展示
- 只有用户明确要求时才实际发送
POST /api/screens不返回image_url;image_url来自后续当前屏幕接口- 只有用户明确要求实际发送时,才执行"推送 → 取图 → 审查 → 优化"闭环
APP_KEY不是这个 skill 的凭据- LaraPaper 会对
content执行Blade::render(),所以可以直接发送<x-trmnl::...>语法,前提是目标实例安装了trmnl-blade
图片审查标准
详见 references/review-checklist.md。审查时先看"正常性",再看"美观性",最后参考"常见修正动作"回到 trmnl-paper-blade 修正。
错误处理
| HTTP 状态码 | 含义 | 处理方式 |
|---|---|---|
| 200 | 成功,返回 {"message":"success"} | — |
| 404 | MAC 或 API key 不匹配 | 检查 MAC 大小写和 API key 是否正确 |
| 422 | 请求体验证失败(缺 image.content) | 检查 JSON 结构是否符合规范 |
| 500 | 服务端渲染失败(Blade 语法错误等) | 检查 markup 是否合法 |
| 连接失败 | base URL 不可达 | 检查 URL scheme 和网络 |
| 取回渲染图失败 | 无法读取当前屏幕 | 检查 access-token、实例可达性,以及是否真的完成了发送 |
image_url 缺失或图片下载失败 | 无法做推送后审查 | 先确认 LaraPaper 当前屏幕已生成,再重试 |
发送前检查
- [ ]
base URL带 scheme(http://或https://,默认优先https://) - [ ]
mac_address与device API key来自用户输入,不自行伪造 - [ ] 先执行 dry-run,核对 endpoint、headers、body 结构是否正确
- [ ] dry-run 输出里凭据保持脱敏;只有用户明确要求时才
--send - [ ] 如果用户要求实际发送,发送后再取一次
image_url做视觉复查(取图路径见references/api-screens.md) - [ ] 若失败,返回 HTTP 状态码或脚本错误,再给最小修复建议
与 trmnl-paper-blade 的边界
trmnl-paper-blade | trmnl-paper-screen | |
|---|---|---|
| 职责 | 生成 markup | 推送 markup |
| 输入 | 内容描述 / 数据 | 已完成的 markup |
| 输出 | Blade 模板代码 | API 请求或执行结果 |
| 验证 | 组件语法、结构规则 | 请求格式、凭据 |
管道衔接
trmnl-paper-blade 生成的 markup 通过以下方式传入本 skill:
- 文件:
--markup-file <path>(推荐,避免转义问题) - 标准输入:
--markup-stdin,适合管道串联
交接格式
推送后如需回传问题给 trmnl-paper-blade,每条问题使用以下最小字段格式:
- issue: 文案在标题栏溢出
location: x-trmnl::title-bar title 属性
suggested_fix: 缩短标题文案,或加 data-clamp="1"issue 描述问题现象,location 定位组件或属性,suggested_fix 给出最小修改方向。
参考资料
- 已验证请求格式与取图步骤:读
references/api-screens.md - 推送后视觉审查标准:读
references/review-checklist.md
可用脚本
- `scripts/push_screen.py` — 构造或发送 LaraPaper 屏幕更新请求(默认 dry-run),可在发送后获取
current_screen并下载渲染图
输出约定
默认按以下顺序输出:
1. endpoint:POST /api/screens 2. 请求头(凭据脱敏) 3. 请求体(markup 单独展示) 4. 如用户要求实际发送,再给发送结果 5. 如用户要求发送后复查,再给 image_url、审查结论与修正建议
/api/screens 已验证更新方式
目录
适用条件
你手上有:
- LaraPaper
base URL(必须带 scheme,默认推荐https://) - 设备
MAC address(服务端自动mb_strtoupper,大小写均可) - 设备
API key(不是APP_KEY) - 已准备好的 markup
请求格式
- endpoint:
POST /api/screens - headers:
id: <MAC_ADDRESS>access-token: <DEVICE_API_KEY>Content-Type: application/jsonAccept: application/json- body:
{
"image": {
"content": "<x-trmnl::screen>...</x-trmnl::screen>",
"file_name": "screen.blade.markup"
}
}字段说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
image | object | ✅ | 外层容器 |
image.content | string | ✅ | Blade / HTML markup,服务端会执行 Blade::render() |
image.file_name | string | — | 可选,脚本默认使用 screen.blade.markup |
响应格式
成功(200)
{
"message": "success"
}注意:POST /api/screens 不会直接返回 image_url。如果要拿到渲染后的图片链接,需要在推送成功后再调用 GET /api/current_screen。
设备未找到(404)
{
"message": "MAC Address not registered or invalid access token"
}常见原因:MAC 地址不匹配(检查是否已在 LaraPaper 注册)或 API key 错误。
验证失败(422)
{
"message": "The image field is required.",
"errors": { "image": ["The image field is required."] }
}常见原因:body 缺少 image 或 image.content 字段,或类型不是 string。
渲染失败(500)
Blade 语法错误会导致服务端渲染异常。检查 markup 中的组件是否存在、属性是否正确。
推送后获取当前渲染图
请求格式
- endpoint:
GET /api/current_screen - headers:
access-token: <DEVICE_API_KEY>Accept: application/json- body:无
说明:上游 LaraPaper 在 routes/api.php 中定义的是 GET /current_screen 路由,经 Laravel API 前缀后实际对外路径为 GET /api/current_screen。认证只需要 access-token,不需要 id。
成功响应(200)
{
"status": 200,
"image_url": "https://larapaper.example.com/storage/images/generated/6f0c6dd2-3d57-4d24-a0cb-example.png",
"filename": "6f0c6dd2-3d57-4d24-a0cb-example.png",
"refresh_rate": 900,
"reset_firmware": false,
"update_firmware": false,
"firmware_url": "https://trmnl.com/firmware",
"special_function": null
}通常在 POST /api/screens 成功后立即调用即可拿到新图;上游实现使用同步生成任务,标准部署下不需要额外轮询等待。
如果目标实例自行改成异步渲染、挂了 CDN / 代理缓存,GET /api/current_screen 也可能短暂返回旧图;这时先等待片刻再重试。
默认假设 image_url 是可直接访问的公开链接;如果实例额外做了鉴权或私有存储,下载图片时需要按部署方式补充认证。
不要用的方式
- 不要把
APP_KEY当作认证凭据 - 不要把 device API key 当成 Bearer token
- 不要用
/api/display/update(那是 Sanctum token 认证,不是设备级) - 不要声称
POST /api/screens本身会返回image_url - 更新只走
/api/screens;推送后的读图只使用/api/current_screen
命令模板
以下命令默认从仓库根目录运行;如果当前目录已在 skills/trmnl-paper-screen/ 下,可把 skills/trmnl-paper-screen/ 前缀省略为 scripts/push_screen.py。
dry-run(默认)
python3 skills/trmnl-paper-screen/scripts/push_screen.py \
--base-url https://larapaper.example.com \
--mac-address AA:BB:CC:DD:EE:FF \
--api-key YOUR_DEVICE_API_KEY \
--markup-file ./screen.blade.markup直接发送
python3 skills/trmnl-paper-screen/scripts/push_screen.py \
--base-url https://larapaper.example.com \
--mac-address AA:BB:CC:DD:EE:FF \
--api-key YOUR_DEVICE_API_KEY \
--markup-file ./screen.blade.markup \
--send发送后输出 image_url
python3 skills/trmnl-paper-screen/scripts/push_screen.py \
--base-url https://larapaper.example.com \
--mac-address AA:BB:CC:DD:EE:FF \
--api-key YOUR_DEVICE_API_KEY \
--markup-file ./screen.blade.markup \
--send --current-screen发送后下载渲染图
python3 skills/trmnl-paper-screen/scripts/push_screen.py \
--base-url https://larapaper.example.com \
--mac-address AA:BB:CC:DD:EE:FF \
--api-key YOUR_DEVICE_API_KEY \
--markup-file ./screen.blade.markup \
--send \
--download-image ./current-screen.png说明:--download-image 会自动触发一次 current screen 读取(/api/current_screen),所以这里不必再额外写 --current-screen。
用 stdin 发送
python3 skills/trmnl-paper-screen/scripts/push_screen.py \
--base-url https://larapaper.example.com \
--mac-address AA:BB:CC:DD:EE:FF \
--api-key YOUR_DEVICE_API_KEY \
--markup-stdin --send <<< '<h1>Hello World</h1>'自定义 file_name
python3 skills/trmnl-paper-screen/scripts/push_screen.py \
--base-url https://larapaper.example.com \
--mac-address AA:BB:CC:DD:EE:FF \
--api-key YOUR_DEVICE_API_KEY \
--markup-file ./screen.blade.markup \
--file-name my-dashboard.blade.markup \
--send推送后视觉审查清单
1. 正常性检查
- 图片能打开,不是空白图、旧图、损坏图
- 没有模板报错残留、乱码、模块消失、布局塌陷
- 核心内容没有被裁切、重叠、严重溢出
- 标题、数值、列表、表格在一眼内可读
- 如果有图片或图标,边缘清晰,不糊成大块灰色
- 最外围内容不要贴边,保留足够安全边距
2. 美观性检查
- 一屏只保留一个主焦点,阅读顺序自然
- 主次分层明确:核心值最大,次要说明降权
- 间距、对齐、分组一致,没有随机松散或拥挤
- 信息密度适合 e-paper,避免把桌面 dashboard 生搬硬套进一屏
- 对比足够,核心标题/核心数值优先保持最高对比度
- 控制大面积黑底白字或深色反白模块,避免画面过重、刷新观感差
- 优先用字号、留白、边框、灰度与 pattern 表达层级
- 文案克制,不让长句抢走核心信息注意力
3. 常见修正动作
- 文本过长:
data-clamp、data-content-limiter、缩短文案 - 表格过满:
data-table-limit="true"、减少列数/行数、改成item - 数值放不下:
data-value-fit="true"、减少附属说明 - 布局拥挤:改用
columns/grid/flex重组模块,删掉次要块 - 层级不清:放大主值、降低次要文本权重、加
divider或重排顺序 - 对比不足:减少大块中灰色背景,改用边框、灰度 token、pattern
- 图片发灰:优先文字化表达;确实需要图片时再考虑
image-dither、image-stroke
4. 迭代节奏
以上属性与组件能力以 trmnl-paper-blade/references/ 为准。
1. 先修最明显的 1-3 个问题 2. 修改后重新校验 markup 3. 再推送、取图、复查 4. 默认最多连续做两轮;若仍有明显 trade-off,再和用户确认
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
DEFAULT_FILE_NAME = "screen.blade.markup"
DEFAULT_TIMEOUT_SECONDS = 30.0
_MAC_ADDRESS_RE = re.compile(r"^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build or send LaraPaper screen update requests.",
epilog=(
"退出码:\n"
" 0 成功(dry-run 预览或请求发送成功)\n"
" 1 请求失败(HTTP 错误或网络错误)\n"
" 2 参数错误(文件不存在、markup 为空等)"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--base-url", required=True, help="LaraPaper base URL.")
parser.add_argument(
"--mac-address",
required=True,
help="Device MAC address (e.g. AA:BB:CC:DD:EE:FF).",
)
parser.add_argument("--api-key", required=True, help="Device API key.")
markup_group = parser.add_mutually_exclusive_group(required=True)
markup_group.add_argument("--markup-file", help="Path to markup file.")
markup_group.add_argument(
"--markup-stdin",
action="store_true",
help="Read markup from stdin.",
)
parser.add_argument(
"--file-name", help="Optional file name for /api/screens payload."
)
parser.add_argument(
"--timeout",
type=float,
default=DEFAULT_TIMEOUT_SECONDS,
help="Per-request timeout in seconds when --send is used.",
)
parser.add_argument(
"--current-screen",
action="store_true",
help="After --send, request current screen info and print the result.",
)
parser.add_argument(
"--download-image",
help="After --send, download current_screen.image_url to a file.",
)
parser.add_argument(
"--show-secrets",
action="store_true",
help="Show full secrets in dry-run output.",
)
parser.add_argument(
"--send",
action="store_true",
help="Actually send the request. Default is dry-run preview.",
)
return parser.parse_args()
def normalize_base_url(base_url: str) -> str:
if not base_url.startswith(("http://", "https://")):
raise ValueError(
f"--base-url 缺少 scheme,应以 http:// 或 https:// 开头: {base_url}"
)
return base_url.rstrip("/")
def normalize_mac_address(mac_address: str) -> str:
normalized = mac_address.strip().upper()
if not _MAC_ADDRESS_RE.fullmatch(normalized):
raise ValueError("--mac-address 格式不正确,应为 AA:BB:CC:DD:EE:FF")
return normalized
def read_markup(markup_file: str | None) -> tuple[str, str]:
if markup_file:
path = Path(markup_file)
if not path.exists():
raise ValueError(f"找不到 markup 文件: {path}")
if not path.is_file():
raise ValueError(f"markup 文件不是普通文件: {path}")
try:
return path.read_text(encoding="utf-8"), path.name
except OSError as error:
raise ValueError(f"读取 markup 文件失败: {path} ({error})") from error
return sys.stdin.read(), DEFAULT_FILE_NAME
def build_request(
base_url: str,
mac_address: str,
api_key: str,
markup: str,
file_name: str,
) -> tuple[str, dict[str, str], dict[str, dict[str, str]]]:
url = f"{base_url}/api/screens"
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"id": mac_address,
"access-token": api_key,
}
body = {
"image": {
"content": markup,
"file_name": file_name,
}
}
return url, headers, body
def build_current_screen_url(base_url: str) -> str:
return f"{base_url}/api/current_screen"
def mask_secret(value: str) -> str:
if len(value) <= 12:
return "****"
return f"{value[:4]}****{value[-4:]}"
def mask_mac_address(value: str) -> str:
parts = value.split(":")
if len(parts) == 6 and all(len(part) == 2 for part in parts):
return ":".join([parts[0], parts[1], "**", "**", "**", parts[5]])
return mask_secret(value)
def mask_headers(headers: dict[str, str]) -> dict[str, str]:
masked = dict(headers)
if "id" in masked:
masked["id"] = mask_mac_address(masked["id"])
if "access-token" in masked:
masked["access-token"] = mask_secret(masked["access-token"])
return masked
def dry_run_payload(
url: str,
headers: dict[str, str],
body: dict[str, dict[str, str]],
show_secrets: bool,
) -> str:
payload = {
"url": url,
"headers": headers if show_secrets else mask_headers(headers),
"body": body,
}
return json.dumps(payload, ensure_ascii=False, indent=2)
def request_text(
url: str,
headers: dict[str, str],
timeout: float,
*,
body: dict[str, dict[str, str]] | None = None,
method: str,
) -> str:
data = None
if body is not None:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method)
for key, value in headers.items():
request.add_header(key, value)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as error:
content = error.read().decode("utf-8", errors="replace")
message = f"HTTP {error.code} {error.reason}"
if content:
message = f"{message}\n{content}"
raise RuntimeError(message) from error
except urllib.error.URLError as error:
raise RuntimeError(f"请求失败: {error}") from error
def request_bytes(url: str, headers: dict[str, str], timeout: float) -> bytes:
request = urllib.request.Request(url, method="GET")
for key, value in headers.items():
request.add_header(key, value)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
except urllib.error.HTTPError as error:
content = error.read().decode("utf-8", errors="replace")
message = f"下载失败: HTTP {error.code} {error.reason}"
if content:
message = f"{message}\n{content}"
raise RuntimeError(message) from error
except urllib.error.URLError as error:
raise RuntimeError(f"下载失败: {error}") from error
def parse_response(content: str) -> object:
try:
return json.loads(content)
except json.JSONDecodeError:
return content
def send_request(
url: str,
headers: dict[str, str],
body: dict[str, dict[str, str]],
timeout: float,
) -> object:
return parse_response(request_text(url, headers, timeout, body=body, method="POST"))
def fetch_current_screen(
base_url: str, api_key: str, timeout: float
) -> dict[str, object]:
headers = {
"Accept": "application/json",
"access-token": api_key,
}
url = build_current_screen_url(base_url)
response = parse_response(
request_text(
url,
headers,
timeout,
method="GET",
)
)
if not isinstance(response, dict):
raise RuntimeError(f"{url} 返回的不是 JSON 对象")
return response
def download_image(image_url: str, output_path: str, timeout: float) -> str:
path = Path(output_path).expanduser()
if path.exists() and path.is_dir():
raise ValueError(f"--download-image 需要文件路径,不能是目录: {path}")
if not path.parent.exists():
raise ValueError(f"下载目录不存在: {path.parent}")
data = request_bytes(image_url, {"Accept": "image/*"}, timeout)
try:
path.write_bytes(data)
except OSError as error:
raise RuntimeError(f"保存图片失败: {path} ({error})") from error
return str(path)
def print_output(payload: object) -> None:
if isinstance(payload, (dict, list)):
print(json.dumps(payload, ensure_ascii=False, indent=2))
return
print(payload)
def build_result(
push_response: object,
current_screen: dict[str, object] | None,
downloaded_image: str | None,
) -> object:
if current_screen is None and downloaded_image is None:
return push_response
result: dict[str, object] = {
"push": push_response,
}
if current_screen is not None:
if "image_url" in current_screen:
result["image_url"] = current_screen["image_url"]
result["current_screen"] = current_screen
if downloaded_image is not None:
result["downloaded_image"] = downloaded_image
return result
def validate_send_options(
send: bool,
show_secrets: bool,
current_screen: bool,
download_image: str | None,
) -> None:
if show_secrets and send:
raise ValueError("--show-secrets 只能用于 dry-run,不能与 --send 一起使用")
if not current_screen and not download_image:
return
if not send:
raise ValueError("--current-screen 和 --download-image 只能与 --send 一起使用")
def resolve_image_url(current_screen: dict[str, object]) -> str:
image_url = current_screen.get("image_url")
if not isinstance(image_url, str) or not image_url:
raise RuntimeError("current screen 接口未返回 image_url")
return image_url
def run_send_flow(
base_url: str,
api_key: str,
timeout: float,
current_screen_requested: bool,
download_image_path: str | None,
url: str,
headers: dict[str, str],
body: dict[str, dict[str, str]],
) -> object:
push_response = send_request(url, headers, body, timeout)
current_screen = None
downloaded_image = None
if current_screen_requested:
current_screen = fetch_current_screen(base_url, api_key, timeout)
if download_image_path:
downloaded_image = download_image(
resolve_image_url(current_screen),
download_image_path,
timeout,
)
return build_result(push_response, current_screen, downloaded_image)
def main() -> int:
try:
args = parse_args()
validate_send_options(
args.send,
args.show_secrets,
args.current_screen,
args.download_image,
)
base_url = normalize_base_url(args.base_url)
mac_address = normalize_mac_address(args.mac_address)
markup, markup_file_name = read_markup(args.markup_file)
if not markup.strip():
raise ValueError("markup 不能为空")
file_name = args.file_name or markup_file_name
url, headers, body = build_request(
base_url,
mac_address,
args.api_key,
markup,
file_name,
)
if not args.send:
print(dry_run_payload(url, headers, body, args.show_secrets))
return 0
current_screen_requested = args.current_screen or bool(args.download_image)
print_output(
run_send_flow(
base_url,
args.api_key,
args.timeout,
current_screen_requested,
args.download_image,
url,
headers,
body,
)
)
return 0
except ValueError as error:
print(str(error), file=sys.stderr)
return 2
except RuntimeError as error:
print(str(error), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())