- 机器人
- 嵌入式
- 强化学习
- 人工智能
- 智能硬件
- 计算机视觉
- 音视频
【免费下载链接】microduck
A Tiny biped duck robot 🦆
导读
本文围绕 microduck 仓库中mediad/webclient/space/README.md这份 Space 元数据文档展开,剖析这个双足小鸭子机器人(microduck)的远程控制台是如何被托管到 Hugging Face Space 上的:机器人本体在局域网内自行提供同一个控制页,而这份 Space 副本则让用户在机器人不在自己网络时也能登录并驾驶它。读完本文,你将掌握:hf_oauth: true这一行元数据为什么是整条远程访问链路的"承重墙";为什么一个"不需要服务器"的静态页面最终选择用 Docker Space 来托管;entrypoint.sh如何把 OAuth 客户端凭据注入页面;页面如何用同一套信令协议走通"局域网 WebSocket"与"远程 SSE + POST"两种传输,以及scripts/publish-console.sh是如何把这页从仓库发布到 Space 的。
一、这份 README 是什么:一个 Docker Space 的元数据卡片
mediad/webclient/space/README.md不是一篇给人读的长文,而是 Hugging Face Space 的配置入口。它顶部的 YAML front-matter 直接决定了这个 Space 的构建方式、端口、鉴权能力与展示信息:
--- title: microduck console emoji: 🦆 colorFrom: yellow colorTo: gray sdk: docker app_port: 7860 pinned: false hf_oauth: true hf_oauth_expiration_minutes: 1440 short_description: Drive a duck that is not on your network. tags: - microduck - webrtc ---关键字段逐个拆解:
sdk: docker:Space 以 Docker 方式运行,而不是静态站点。正如下文要讲的,这个选择"是伤疤而非偏好"——静态 Space 的变量注入在这个项目上失效过,Docker 是验证过的、不会静默失效的替代路径。app_port: 7860:容器内服务监听的端口,与 mediad/webclient/space/Dockerfile 里的EXPOSE 7860严格一致(Dockerfile 注释明说:"HF's default for a Docker Space, andapp_portin the README says the same number")。hf_oauth: true:这是整个卡片里最重要的一行。它让 Hugging Face 为这个 Space自动创建 OAuth 应用,并把OAUTH_CLIENT_ID、OAUTH_SCOPES、OPENID_PROVIDER_URL等值放入容器的运行环境。README 原文用"load-bearing"(承重)这个词强调:没有它,控制台无法让任何人登录,页面会直接明说这一点。hf_oauth_expiration_minutes: 1440:登录态的有效时长,1440 分钟即一天。页面把登录结果存在localStorage,并在使用前检查过期时间。
front-matter 之下的正文则回答了一个关键问题:为什么远程控制台要长成现在这样。它点出了几个核心设计事实:
- 远程页与机器人本机伺服的是同一个页面(mediad/webclient/index.html),只是传输方式不同——局域网内走
ws://<robot>:8443,远程则经由 rendezvous 服务; - 页面通过 Hugging Face 登录,用 OAuth token 向 rendezvous 证明身份;
- 这份 Space 源码不应直接编辑,它是从仓库发布的部署产物。
二、为什么"一个不需要服务器的页面"用上了 Docker Space
这是本 README 最有信息量的部分:静态 Space 本应自动把window.huggingface.variables注入页面,但对这个 Space 它从未生效过。
据文档记录,注入失败经历了多次尝试:一次元数据变更、一次隐私开关切换、一次删除重建、甚至给页面补上真正的<head>让注入器有处可写(此前页面长期是"裸 doctype + 内容")。Hugging Face 自己的 API 报告两个 Space 完全一致——都是sdk: static、公开、hf_oauth: true、RUNNING——但一个能注入、一个不能。既然官方文档描述的注入路径不可依赖,项目转向了 Docker Space 的"另一条文档化路径":hf_oauth: true把客户端 ID 放进环境变量,容器自己把它写进页面。
于是python3 -m http.server成了整个服务器。Dockerfile 里写得很直白:
# `python -m http.server` is the whole server: one file, GET only, no upload path, no config. The # alternative was nginx, which is a package, a config file and a user to get wrong for a page. WORKDIR /app COPY index.html /app/index.html COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh EXPOSE 7860 ENTRYPOINT ["/app/entrypoint.sh"]选择python -m http.server而非 nginx 的理由是:一个文件、只读 GET、无上传路径、无配置——对于一个"只需把页面端出去"的任务,nginx 的"一个包、一个配置文件、一个用户"都是出错点。
设计上的另一个微妙点:Docker 不是"为 Docker 而 Docker",而是为了能拿到并注入 OAuth 客户端 ID。这也是 mediad/webclient/space/entrypoint.sh 的全部意义。
三、entrypoint.sh:把运行时变量写进页面的那"八行 sh"
entrypoint.sh是让远程登录能工作的机制核心。它做的事情可以概括为:复刻静态 Space 本应注入的window.huggingface.variables对象,但数据来自容器自身环境。
3.1 注入哪些变量
脚本定义了一个白名单:
PUBLIC = ("OAUTH_CLIENT_ID", "OAUTH_SCOPES", "OPENID_PROVIDER_URL", "SPACE_HOST", "SPACE_ID")只有这些"静态 Space 会暴露给页面"的变量会被写入;缺失的变量直接省略而非输出null,这样页面里的||兜底逻辑行为与静态 Space 完全一致。同时,环境里确实存在OAUTH_CLIENT_SECRET,但脚本故意不把它列入清单——浏览器端走的是 PKCE 流程,不需要 secret;客户端 ID 本身是公开值,授权不了任何事。README 也强调:secret 绝不能进入页面。
3.2 用 Python 而非 sed 做替换
注入不是简单字符串替换,因为被注入的值现在是JSON:provider URL 里满是sed替换语法视为特殊字符的符号,用sed会静默产出"能解析但谁也登录不了"的页面。因此脚本内嵌一段 Python:
variables = {name: os.environ[name] for name in PUBLIC if os.environ.get(name)} bootstrap = "<script>window.huggingface=%s;</script>" % json.dumps({"variables": variables})3.3 锚定<head>:一次失败留下的教训
注入位置也有讲究。页面 mediad/webclient/index.html 的注释反复强调<html>/<head>/<body>骨架是"承重"的——此前页面没有<head>,导致没有任何地方可注入、没有客户端 ID、控制台无法登录且无任何报错。脚本用锚定正则定位独占一行的<head>开始标签:
HEAD = re.compile(r"^([ \t]*)<head>[ \t]*$", re.MULTILINE)- 要求恰好匹配一次(
HEAD.search未命中则退出,提示"页面无法让任何人登录"); - 找到两处
<head>也拒绝猜测("more than one line; refusing to guess"); - 之所以要锚定到行首,是因为页面自身的注释里也含
<head>字样——若做"第一个匹配"替换,bootstrap 会被写进 HTML 注释里,永不执行。
3.4 注入失败时的行为
脚本对"环境里没有OAUTH_CLIENT_ID"并不致命退出,而是打印告警后照常起服务。README 解释了这一取舍:"a console that serves and explains beats one that will not start"——一个能服务并解释原因的页面,好过一个根本起不来的页面。最常见的诱因是 README 里少了hf_oauth: true。
启动成功后,exec python3 -m http.server 7860 --directory /srv对外提供页面。这套"容器从环境变量注入 OAuth 变量"的机制,与telepresence的server.mjs以及spaces/policy-playground所用的机制一致(spaces/policy-playground/entrypoint.sh、spaces/policy-playground/web/src/auth.ts里同样读取window.huggingface.variables)。
四、登录与作用域:只要openid profile,token 只用来证明身份
4.1 为什么只要最小作用域
页面登录只请求openid profile。README 明确指出,这个 token 的全部工作是向 rendezvous 服务证明一个身份——服务通过whoami-v2解析 token 并从中读取用户名。向浏览器索要仓库级作用域(如write-repos)正是 docs/design/remote-access-design.md §2.4 要在机器人端修掉的那个错误(该节记录了机器人设备流 token 携带全部作用域的问题,并主张收敛为openid profile read-repos)。
页面代码 mediad/webclient/index.html 中的实现:
function oauthScopes() { return hfVariables().OAUTH_SCOPES || "openid profile"; }作用域从 Space 注入的对象读取,而非写死在页面里:因为 Space 的应用是按 README 元数据供给的,OAUTH_SCOPES是 Hugging Face 回报"它给这个应用配了什么";一个请求自己应用并不拥有作用域的页面,会产生"谁都读不懂的拒绝"。
4.2 登录记住多久:hf_oauth_expiration_minutes
README 强调:"A sign-in lastshf_oauth_expiration_minutesand no longer."(本 Space 为 1440 分钟,即一天)。页面把 OAuth 结果存入localStorage(键为duck-console-oauth-v2),并在把 token 交给 rendezvous之前检查过期时间;过期的 token 视同未登录,页面会重新发起登录,而不是误报"你的鸭子不在"。
页面代码rememberedSignIn()是这段逻辑的落点:解析存储的 JSON、校验accessTokenExpiresAt,过期即删除并重新登录。代码注释还记录了一个真实 bug——过期时间"被记住但从未被读取",导致一天后 header 仍显示你是谁、而 rendezvous 却解析不了 token,表现为"鸭子不在"。
登录链路的健壮性设计(signIn()与resumeSignIn())同样值得注意:
- 每个 await 都与 10 秒超时赛跑(
SIGN_IN_TIMEOUT)。原因:地址栏残留一个已用过的?code=,会让oauthHandleRedirectIfPresent永久挂起,页面卡在"asking Hugging Face who you are…"。超时消息会点名"把 URL 里的 code 删掉"。 - OAuth 重定向回调在页面加载时立即处理(
resumeSignIn挂在load事件上),而不是等用户点 connect——否则 code 永远不被兑换,形成登录循环。 - 重定向 URI 必须精确匹配:
OAUTH_REDIRECT = location.origin + location.pathname,含尾斜杠。 - 签名库锁定版本:
@huggingface/hub@2.11.2(HUB_ESM),因为@1曾解析到某个oauthHandleRedirectIfPresent不返回结果的版本,把登录变成重定向循环。
五、一页两传输:同一套信令信封,两条到达路径
README 用一句话概括页面架构:"One page, two transports."(一页、两种传输)。这是理解整个 Space 的关键:
- 由机器人伺服时(局域网场景):页面从
http://<robot>:8080/到达(或用duckctl open自动定位),打开ws://<robot>:8443直连机器人自己的信令服务器。因为页面就是目标机器人发的,host 直接取location.hostname,端口由 mediad 伺服时注入({{SIGNALLING_PORT}}),无需输入任何地址。 - 从此 Space 伺服时(远程场景):页面运行在 https 下,浏览器根本不允许打开
ws://(混合内容被直接拦截),因此它改为读取 rendezvous 服务的 SSE 事件流、以POST /send回发,携带相同的信封,只是带上了逐跳的 peer/session id。
页面代码中的选择逻辑:
const SERVED_PORT = Number("{{SIGNALLING_PORT}}"); const SERVED_BY_ROBOT = Number.isFinite(SERVED_PORT); const REMOTE = PARAMS.get("mode") ? PARAMS.get("mode") === "remote" : !SERVED_BY_ROBOT && location.protocol === "https:";?mode=lan/?mode=remote参数可强制覆盖,用于在"本会选另一种传输"的环境里测试某一种。
两种传输共享同一套信令处理逻辑onSignalling()(README 指出这正是 docs/design/remote-access-design.md §3.2 所说的"对不透明 payload 做翻译的桥"而非解析器)。远程侧的send()有一个 LAN 场景不存在的陷阱:startSession和list的应答会出现在POST /send的 HTTP 响应体里,其余消息走事件流——代码注释记录了这个曾导致空 peer connection 失败的坑,因此响应体会走与事件流相同的处理函数。
5.1 远程事件流:用 fetch 而非 EventSource
因为 SSE 流需要Authorization头而EventSource无法设置请求头,页面用fetch读取流并自行切分(拒绝把 bearer token 放进 query string——那会出现在 Space 的访问日志和每一层代理里)。实现要点包括:入站时统一\r\n为\n(SSE 允许 CRLF,代理有权改写,按\n\n切分会静默吞掉所有消息)、只保留data:载荷、跳过注释型 ping。
5.2 远程列表过滤:meta.kind === "microduck"
远程模式下,rendezvous 会列出该账号拥有的所有机器人,包括reachy_mini家族的 producer。onSignalling对list应答按meta.kind过滤出 microduck,避免把 mini 当作鸭子驾驶(会给它发它不提供的方法名):
if (REMOTE) producers = producers.filter((p) => (p.meta || {}).kind === KIND); // "microduck"账号名下有多只鸭子时出现选择器(<select id="robots">);单只则直接使用。producer 的meta由 mediad/src/producer.rs 填充(name、serial、release、api_version),在会话建立前就能让 header 显示机器人身份。
5.3 远程页面还自带 TURN 中继凭据
远程会话里页面还会以自己的 token向 TURN 凭据服务(TURN_CREDENTIALS_URL,即https://fastrtc-turn-service.hf.space/credentials)换取 Cloudflare 中继凭据(TTL 600 秒),让自己这端也能提供 relay 候选。README 提及的动机记录在 docs/design/remote-access-design.md §6:一条连接只需要一个可用的 relay 候选,而这个候选必须来自浏览器这端——iPhone 在移动网络下没有 IPv4 套接字,机器人提供的裸 IPv4 relay 字面量它根本发不出去。
六、部署管线:publish-console.sh与"不要直接编辑 Space"
README 明确警告:不要直接编辑这个 Space。页面源码(mediad/webclient/index.html)必须活在仓库里,因为它要追踪两件同样活在仓库里的东西:信令协议和机器人自己的方法名。一份放在 Space 仓库里的副本会与两者双双漂移。发布由 scripts/publish-console.sh 完成,其要点:
- 用法:
scripts/publish-console.sh [--space <org/name>] [--dry-run],默认目标pollen-robotics/microduck-console;推送需要具备该 Space 写权限的 HF token(hf auth login存储,脚本自身从不读取)。 - 替换两个 token:
{{API_VERSION}}:从 duck-ipc-proto/src/lib.rs 的pub const API_VERSION读出并替换,让页面能向使用者报告"页面与机器人 API 版本不一致";{{CONSOLE_BUILD}}:替换为"短 commit + 页面自身 hash + 时间戳",页面加载时作为第一行日志打印——因为"静态宿主缓存、浏览器缓存更狠",一个小时的调试可能花在一个从未被加载的修复上,页面必须能自己说出版本。{{SIGNALLING_PORT}}故意保留不替换。页面把"未替换的端口"解读为"没有机器人伺服我"——这在 Space 上恰好为真,也正是它选择 rendezvous 传输的依据;若替换掉,页面会试图对 Space 打开 WebSocket。
- 三道发布前自检:页面必须有
<head>(否则 Space 无法注入 OAuth 客户端 ID);页面不得出现OAUTH_CLIENT_SECRET(PKCE 流程不需要 secret);{{SIGNALLING_PORT}}必须仍在(否则 Space 副本会走 WebSocket 路径)。 - 发布动作:clone Space 仓库、覆盖
index.html、README.md、Dockerfile、entrypoint.sh四个文件、git diff --quiet判断是否有变化、提交并推送(提交信息如Console from microduck <revision> (api v<N>))。
也就是说,Space 目录(README.md+Dockerfile+entrypoint.sh)是部署清单,页面本体才是真正的源代码,两者由publish-console.sh捆绑发布。
七、从页面源码看远程控制台能做什么
虽然 Space README 是元数据卡片,但它服务的页面(mediad/webclient/index.html,单文件、无构建步骤、无 npm)本身就是"远程驾驶一只鸭子"的完整 UI。页面注释说得很清楚:它直接说 gst-plugins-rs 的信令协议,而非使用 gstwebrtc-api JS 库,"一个需要 npm 的客户端,是没人会运行的客户端"。页面能力包括:
- 视频与检测:
pc.ontrack把机器人推来的视频接到<video>;检测框以 SVG 叠加,坐标使用"竖立帧"自身像素(相机侧装 90°,画面旋转在 GPU 完成,不耗 CPU——页面注释记录过 pipeline 里旋转曾花掉 145% 单核、CPU 97°C 并降频到 408 MHz 的教训)。检测框 2.5 秒无更新即过期清除,避免"最后一只鸭子永远画在画面上"。 - 驾驶:W/S/A/D、Q/E 转向,全偏转 0.3 m/s 与 1.5 rad/s(与
padd默认一致);意图以 10 Hz 持续重发,机器人侧 500 ms 收不到即自行停止(deadman)。虚拟摇杆与键盘并存,摇杆被按住时优先。 - 姿态与技能:
robot.enable/robot.init/robot.relax/robot.stop/robot.shutdown;技能列表从robot.policies读取(技能是配置项,写死清单会既多又少);声音含chirp、greet、wheee (hold)等,wheee是按住持续、松手释放的"骑乘"。 - 遥测:
robot.subscribe2 Hz 帧 + 每 2 秒轮询robot.health(mode、policy、safety 标志、电池电压、电机/板卡温度),limited_by直接说明限制原因。 - 视线控制:在画面上拖动即可让机器人看向某点,
robot.look解算 IK,超出可达范围时页面显示 "gaze clamped"。 - 原始 JSON-RPC 框:抽屉里的 raw 输入框可发送任意方法,包括两个被拒的示例按钮
net.connect与system.pairingPin(BLE 允许、WebRTC 拒绝),用于证明路由表确实在被查询——这正是 mediad/src/route.rs 许可范围的体现。 - 单文件约束:页面依赖
include_str!被 mediad 嵌入伺服,到达它"是一个地址而非一条命令"(http://<robot>:8080/)。
这些能力在两个传输下完全相同——LAN 与远程共用onSignalling、共用控制通道上的 JSON-RPC 匹配(call/tell按 id 匹配应答),这正是"一页两传输"设计成立的原因。
八、安全与边界:secret 不进页面、作用域最小化
纵观整个 Space 设计,安全边界是刻意且成体系的:
| 层 | 措施 |
|---|---|
| 凭据 | OAUTH_CLIENT_SECRET存在于容器环境,但从不写入页面(PKCE 浏览器流程不需要 secret),且publish-console.sh对产物页面做 grep 自检 |
| 作用域 | 只请求openid profile;token 的唯一职责是向 rendezvous 证明身份,与 docs/design/remote-access-design.md §2.4 对机器人端 token 的收敛主张一致 |
| 登录态 | 过期即视为未登录(hf_oauth_expiration_minutes),宁可重新登录也不误报"鸭子不在" |
| 传输 | 远程页在 https 下只能走 rendezvous(ws://被浏览器按混合内容拦截),token 只进Authorization头、不进 query string |
| 注入 | 注入正则锚定到独占一行的<head>且要求唯一,bootstrap 不可能被写进注释 |
页面还能通过?client_id=覆盖客户端 ID——这是"新应用在注册进任何地方之前先试用"的通道,与 docs/design/remote-access-design.md §5.0 记录的 GitHub Pages 备选方案同理(页面从 URL 取客户端 ID,意味着托管方可以随时更换)。
九、限制与前提
需要说明的边界条件,全部以当前仓库为准:
- 远程可达依赖外部服务:rendezvous(
pollen-robotics-reachy-mini-central.hf.space)与 TURN 凭据服务都是 Hugging Face 生态内的 Space,页面在?rendezvous=、?turn=参数下可指向本地副本或假服务做测试,但默认配置下远程控制台依赖这两者在线。 - 登录是浏览器 OAuth 流:与机器人端的设备码流(
robotctl account login/duckctl account login)不同,本页面用的是@huggingface/hub的oauthLoginUrl/oauthHandleRedirectIfPresent,PKCE、无 secret;且登录库锁定2.11.2版本。 - 账号名下才能列机器人:页面只列出"该 HF 账号拥有"的 producer;一只机器人需要先完成账号登录(约在
robotctl account login后三十秒内出现在列表,且保持联网)。 - 不建议直接编辑 Space:部署目标由
scripts/publish-console.sh覆盖式发布,页面源码的唯一真源在仓库内。
十、结语
这份 Space README 虽短,却浓缩了 microduck 远程访问设计的全部关键决策:hf_oauth: true提供 OAuth 应用与其环境变量、Docker Space 提供可靠的注入路径、entrypoint.sh提供运行时注入、index.html提供"一页两传输"的客户端、publish-console.sh提供从仓库到 Space 的发布管线。它回答了远程控制台最本质的那个问题——"一个页面从哪里拿到 token":答案是 Space 元数据本身。如果你要在自己的部署里复刻这套方案,记住 README 里那句忠告即可:别直接编辑 Space,改仓库里的页面,然后跑publish-console.sh;而当登录失效时,先检查 README 里hf_oauth: true是否还在——它是一切的地基。
- 机器人
- 嵌入式
- 强化学习
- 人工智能
- 智能硬件
- 计算机视觉
- 音视频
【免费下载链接】microduck
A Tiny biped duck robot 🦆
相关推荐
microduck 的 WebRTC 控制台:从测试页到机器人驾驶台(mediad 与 duckctl 全链路实战)
microduck 的 WebRTC 控制台:从测试页到机器人驾驶台(mediad 与 duckctl 全链路实战) 本指南围绕 microduck 仓库中 d
机器人嵌入式强化学习人工智能智能硬件计算机视觉音视频microduck WebRTC 远程访问设计:本地信令、双数据通道与控制通道如何接上机器人 API
microduck WebRTC 远程访问设计:本地信令、双数据通道与控制通道如何接上机器人 API 本篇基于 microduck 仓库的设计文档 remote
机器人嵌入式强化学习人工智能智能硬件计算机视觉音视频AI驱动的数据分析:Awesome Claude Skills业务智能平台
AI驱动的数据分析:Awesome Claude Skills业务智能平台 在当今数据驱动的商业环境中,高效的数据分析能力已成为企业竞争的关键。Awesome
AI 技能AI 插件人工智能工作流自动化
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考