dsh-pad-touch
把 PS5 手柄的触摸板接进 DeepSeek Harness 会话——摸一下,它会应你。
一个 DSH 插件。它自己不懂手柄协议,也不决定该不该回应;它只做一层翻译:
你的手指 → 触摸板 → dualsense-pad(本地 7778)→ 结构化事件
→ dsh-pad-touch(本插件)→ 一句话投进会话
→ 会话里的那个 AI 开口,同时手柄亮灯、震动回手
为什么做这个
文字聊天是「我打字、它回答」。触摸板让它变成「我摸它、它有反应」——有速度、有长短、有位置,还会回手震一下。
这套东西最早来自 @DukeCCY/dualsense-pad:它只干「手指动作 → 事件」这一段,回不回、怎么回应全交给上层。本插件就是那个上层。
依赖
- 上游底座:
DukeCCY/dualsense-pad(MIT,纯 Python 标准库) - 本仓库
patches/dualsense-pad.patch:上游不带的几处手感修正(见「底座改动」一节) - DSH:本插件是 profile bundle
- 一根 USB 直连的 DualSense(蓝牙不行,这是底座限制)
底座单独能跑起来的话:
python -m dualsense_pad list
# → 054C:0CE6 DualSense ... ← 触摸板
python -m dualsense_pad serve --port 7778
安装
# 1. 底座 + 手感补丁
git clone https://github.com/DukeCCY/dualsense-pad
git -C dualsense-pad apply /path/to/patches/dualsense-pad.patch
# 2. 插件
dsh plugin --profile desktop add "link:/path/to/dsh-pad-touch"
装完 plugin_manager 里能看到 include:dsh-pad-touch,宿主侧热加载,不用重启 DSH;客户端那半边(输入框左边的 🎮)要刷新页面。
用法
输入框左边一个 🎮,三个状态:
| 状态 | 外观 | 含义 |
|---|---|---|
| idle | 灰色圆圈 | 没起服务,零开销 |
| starting | 圆圈转动(类似鼠标加载) | 正在拉起 python 底座 |
| running | 蓝色圆圈(带一点微光) | 正在接收触摸 |
圆圈是用 SVG <circle> 画的,不走 CSS 的 border-radius——这样在 Electron / 浏览器 / 任何缩放比下都是正圆。
点一下开始、再点一下停;停止触摸 3 秒后圆圈由蓝转灰,5 秒后服务自动关掉、圆圈消失,只剩手柄图标。
手指还在触摸板上时不会倒计时(插件每轮读 /state 的 active 续命)——否则底座要等 settle 秒才成形事件,中间这几秒会被误判成空闲,把正在摸的人的服务关掉。
摸一下之后:
- 立刻按手势换灯条颜色(不等模型)
- 手指一抬,手柄震一记(即时回手)
- 落定后把这句话投进会话:
【手柄】用户刚摸了你的脚:慢慢摸,持续 2.0 秒,速度 23。这是真手指在触摸板上动,
不是打字——按你自己的反应回他,别复述这条提示。
会话跟随:摸谁,就落到谁那儿
插件不靠配置猜你在看哪个窗口——输入框左边那个按钮自己就看得见。
每次挂载、每 4 秒、以及你按下 🎮 的那一刻,它依次尝试:
- 本页面的 React fiber(从自己的 DOM 节点往上翻
memoizedProps)——最准,不受别的标签页影响 - DOM 上的
data-session-id这类属性 - 地址栏
localStorage的dsh.sessions.current——只能放在最后当兜底
然后把结果 POST /dsh-pad/api/session 报给宿主,宿主投递时优先用它(tuning.json 里的 sessionId 只在探测失败时兜底)。
为什么
localStorage不能放前面:它同一 origin 所有标签页共享。开两个窗口时,一个窗口会读到另一个窗口写进去的会话 id,消息就投到隔壁去了——本插件踩得最疼的坑之一。
探测失败也不会一声不吭:pad-touch.log 里会留一行 session probe EMPTY ...,带上当时页面上能看到的全部线索。
配置
tuning.json 是热读的,改完一秒生效,不用重启 DSH:
{
"autoOffMs": 5000,
"minIntervalMs": 2000,
"hapticScale": 2.5,
"light": true,
"haptic": true
}
| 键 | 作用 | 说明 |
|---|---|---|
autoOffMs |
空闲多久关服务 | 太短会「停一下就摸不到」,太长会一直挂后台。5000 = 停 5 秒圈圈消失;与客户端「3 秒转灰」联动 |
minIntervalMs |
两条消息最小间隔 | 兜底防刷屏;单位毫秒 |
flushMs |
停手多久把这一串触摸汇总成一条 | 2500 = 停 2.5 秒发一条 |
hapticScale |
震动强度倍率 | 0.3–2.5。1 是原始参数,实测 1.6 以下基本摸不出来 |
trigger |
扳机阻力开关 | false 就不碰扳机 |
triggerStart / triggerEnd |
扳机行程起点 / 终点力度 | 0–255,默认 76→178(三成到七成) |
light / haptic |
灯条 / 震动开关 | — |
userName |
消息里怎么称呼你 | 留空就是「用户」;想让它叫名字就填这里 |
sessionId 平时留空:插件会自己探当前窗口(见上面「会话跟随」),探测失败时才退回这个兜底值。
cordis.patch.yml 里只放启动期参数(padUrl / repoDir / pyExe / sessionId)。
换会话不用再改配置——插件会探当前窗口;sessionId 只是探测失败时的兜底。
手势 → 灯条 → 震动
| 手势 | 含义 | 灯条 | 震动(原始,乘 hapticScale) |
|---|---|---|---|
| stroke | 慢慢摸 | 蓝 0,110,255 |
70 / 25 / 140ms |
| tap | 轻点 | 琥珀 255,190,0 |
150 / 60 / 60ms |
| pat | 拍 | 橙 255,140,0 |
170 / 95 / 80ms |
| swipe | 划 | 青绿 0,220,140 |
90 / 40 / 110ms |
| rub | 蹭 | 紫 170,90,255 |
80 / 35 / 150ms |
| circle | 画圈 | 粉 255,90,180 |
110 / 55 / 130ms |
| pinch | 双指捏 | 红 255,70,70 |
190 / 110 / 70ms |
| spread | 双指撑开 | 亮青 70,200,255 |
190 / 110 / 70ms |
扳机:扣下去也算摸
两根扳机是两条独立的路:
- 阻力:服务起来就设一次,随行程从
triggerStart(默认 76,三成)线性升到triggerEnd(默认 178,七成);服务停时释放。改tuning.json里的trigger/triggerStart/triggerEnd,一秒生效。 - 扣扳机 = 摸一下:按下记起点,松开时按「按了多久」落定成一条触摸事件,和触摸板走同一条出口。左扳机算左手,右扳机算右手——它们没有位置信息,只有左右。
两个阈值(TRIGGER_DOWN=100 / TRIGGER_UP=30)中间留空档,防止手抖在临界值上来回抖出好几段。
扳机效果是持续状态:服务一关它还留在手柄上,手上会一直有股没来由的阻力。所以停服务前必须先发一帧清除、等它真写出去,再杀进程。
一次摸很多下?攒成一条发
连着摸十下,不会往会话里抛十条消息。停手 flushMs(默认 2500 毫秒)之后合并成一条:
【手柄】用户刚摸了你的左手和右手:连着 11 下,总共 5.9 秒(慢慢摸 ×11)。
其中 10 下是扣扳机——按你自己的反应回他
几下、总共多久、摸的哪儿、什么手势、几次是扳机,一句话说完。
底座改动(patches/dualsense-pad.patch)
上游有几处手感问题,本仓库带一个补丁。不改也能用,但震动会时有时无。
live()让路:手指还搭在板上时,采样循环每 80ms 调一次live(),速度低于阈值就stop()——刚写进去的回手震动会被立刻清掉,表现为「只有第一次有震动」。补丁让live()在 pulse 未结束时直接返回。_collect()补实时反馈:手指一抬,底座进_collect等 settle 秒确认落定,这期间读循环被独占且不产生实时震动——「抬手再摸」那一段是哑的。补丁在_collect里补上live()。- 抬手即震:手指离开时给一记顶格脉冲(
255/138/250),不等落定。 - 写入节奏 250Hz → 62.5Hz:
_loop原为 4ms 一拍,live()的断续 pattern 每拍都在变,等于每秒写 250 帧,对 USB HID 太密。 _last_sent只在写成功后更新:原来写失败也更新,一失败就再也不重试。
踩过的坑
- 参数不是越小越温柔:
70/25/140在手柄上几乎摸不出来,175/62/224才有。手柄马达有启动阈值。 - 延迟比强度更要命:settle(1.5–2s)+ 轮询(1s)+ 出帧(0.2s)≈ 2.7 秒,手都放下了才震——那时候你已经不会把它和刚才的触摸联系起来了。
- settle 是双刃剑:压到 0.6 秒很快,但手指停一下就断成两段;2 秒能容忍停顿,代价是落定慢。
write ok不等于手柄在震:WriteFile成功只说明帧进了驱动。排查要记「请求 → 实际写入 → 手柄反应」三层,缺一层就会误判。- 服务跑在隐藏窗口里(
stdio: 'ignore'),print看不到,调试得写文件日志。 - 客户端插件改了要刷页面:
lib/client.js是运行时从磁盘取的,但页面已经把它加载进内存了——改完按 F5。宿主侧(lib/index.js)也一样:ESM 模块有缓存,set_bundle热加载只是重新挂载,不会重新读文件。 ensureCss()写成「已存在就跳过」会害死人:DSH 重连时 DOM 不重建,旧<style>一直占着那个 id,新样式永远进不来。改成每次都替换。border-radius: 50%在某些容器里画不出圆:宽高被撑成方的,圆角没吃满。最后改用 SVG<circle>,跟 CSS 无关,任何环境都是正圆。localStorage是全标签页共享的:见上面「会话跟随」——拿它判断「当前是哪个窗口」必错。- 往「打开着但空闲」的旧会话投递会撞锁:DSH 一个会话同一时刻只允许一个 write handle(进程内
claimWrite+ 内核级SessionWriteLease),有 agent 正在跑的会话能直接复用、能投;只是「开着」的旧会话会抛already owned by an active write handle。关掉多余的 DSH 实例/窗口,或者重启一次就好。 - 「清除」比「设置」容易漏:清除帧被
if any(bytes)挡掉(全 0 看着"没什么可写")、清除代码写在if (proc)里(接管来的服务被判空)、状态机判定"没变化"不写帧(而"本来全 0"恰恰最该发一帧)——三处任意一处都能让清除变成空操作,症状完全一样:手上阻力还在。判据:「我发了命令」不等于「设备收到并执行了」,按「命令发出 → 服务收到 → 帧写出去 → 设备反应」四层查。 - 发完就杀进程,帧可能还没出门:底座写线程 16ms 一拍,接口返回只代表请求收到了。实测有次
trigger off和service exit只隔 4 毫秒,那一帧根本来不及写,手柄上照样硬着。清完要留 ~150ms 再杀。
已知边界
- 只认 USB 形态的 DualSense(底座的限制)
- 坐标方向按底座的默认;手感不对调它的
vertical/flip_u - 摸一下会烧一轮 token(有节流);不想烧就
enabled: false - 别的 DSH 实例里摸不到:插件装在哪个 profile,就只有那个实例的界面里有按钮。要让网页端(
cli.js web)也能摸,得在webprofile 里再装一份 - 会话探测依赖 DSH 当前的页面结构(React fiber /
dsh.sessions.current)。DSH 大改版后可能要跟着改,pad-touch.log会直接告诉你探测失败在哪一步
致谢
- DukeCCY/dualsense-pad(MIT)——整套东西的地基。250Hz 读坐标、8 类手势、
/pulse与/light的回手接口,全是它做的;而且它刻意只做「手指 → 事件」这一段,回不回、怎么回应交给上层。没有它,这个插件只是空壳。 - DeepSeek Harness——插件机制、插槽、
sessionController都是它给的。
patches/dualsense-pad.patch 是我对上游的本地修改,不是官方版本。上游以后要是自己修了这几处,这个 patch 就可以直接扔掉。
License
MIT
No comments yet. Be the first to write one.