TL;DR: 本教程介绍如何通过免费开源的 Hammerspoon 监听特定 USB 键盘/鼠标拨动(或重新插拔)事件自动切换显示屏信号源。方案使用 brew install m1ddc 获取指令集,通过定位目标显示器独立的 UUID 控制双屏并发切换,并在 Lua 中封装了更为健壮的非阻塞(异步回调)延时命令机制,解决了唤醒时设备识别延迟的问题。

MacBook 和 Win 主机共享一套键鼠和显示器,键鼠当前是通过外接 USB 切换器进行切换。显示器通过(雷雳4/Type-C)和 Mac 连接,同时通过(DP/HDMI)和 PC 连接。
这样切换键鼠很方便,我只要按一下线控开关就在物理上实现了 USB 键鼠上行通信通道的切换。但是这个方案存在一个体验痛点:按下线控开关切换键鼠 USB 上行的同时,无法做到显示器输入源同步自动切换。
大致的线缆拓扑如下:

因为 DELL U2725Q 等多款戴尔高端显示器机身上往往带有 USB 3.0 HUB ,所以拓扑上 USB 切换器连接 Mac 的上行是接在显示器一端的,这样显示器和 MacBook 只需要一根 USB-C 线连接就可以实现外接视频信号、键鼠数据下行控制、PD快充、有线网卡等一体化功能。
DDC/CI 协议:通过命令行切换输入源
为了实现软件层的显示器信号切换,我们需要引入 m1ddc 这个项目。它可以实现 Apple Silicon (M系列芯片) 的 Mac 通过 DDC/CI 硬件控制协议发送指令,直接控制外接显示器的系统属性(亮度、对比度、输入源)。
安装与多显示器 UUID 识别
我们可以直接通过 Homebrew 安装 m1ddc:
由于我的桌面主要是双显示器方案(一台 DELL U2725QE 和一台 DELL U2720Q),我们要让脚本精确识别是控制哪一台显示器,避免由于设备拔插顺序不固定导致通道发错。首先接好两台显示器,在终端中执行以下命令列出显示器详情:
1
2
3
|
$ m1ddc display list
[1] DELL U2725QE (1D9A4CC2-9ACC-404C-954D-34EAEFC590A5)
[2] DELL U2720Q (AE60FFA9-619A-48E1-A9F5-995880A1C270)
|
记住括号内的 UUID,接下来的指令我们将通过指派 UUID 来独立切换它们的输入通道。假设将 U2725QE 切换至 USB-C:
1
2
|
# 指定显示器的 UUID (例如: 1D9A4CC2-9ACC-404C-954D-34EAEFC590A5) 切换到通道值 25
m1ddc display 1D9A4CC2-9ACC-404C-954D-34EAEFC590A5 set input 25
|
Hammerspoon 检测 USB 状态与异步切换控制
为了解决前面提到的“最后一公里”痛点,我们的构想是:在 Mac 后台持续检测特定 USB 设备连通状态。由于 USB 切换器按键被按下时,外设(例如我的 ROG 键盘接收器)对 Mac 系统而言实质上就是一次瞬间的“物理断开与重新连接”。如果在 Mac 上发现这个设备接通了,说明用户目前希望用 Mac,我们便命令显示器切到 Mac;如果发现设备断开了,说明用户把键鼠切给 PC 去了,此时命令显示器切到 Windows PC 端口即可。
Hammerspoon 堪称 macOS 环境下的自动化效率神器,它桥接操作系统的底层事件系统与灵活的 Lua 脚本引擎。
我们可以在家目录的 .hammerspoon (而非单纯的 modules)结构下进行组织:
1
2
3
4
5
|
~/.hammerspoon
├── Spoons
├── init.lua
└── modules
└── usb_script.lua
|
异步防阻塞 Lua 脚本
由于 DDC 通道控制本质上存在物理延迟,如果使用单纯的同步命令行,可能会阻塞 Hammerspoon 的主线程导致 Mac 菜单栏瞬间无响应;同时在唤醒 Mac 时,USB 树建立是需要时间的,瞬间下发的命令通常会失败。
因此,在这套 Lua 脚本中做了如下设计:
- 异步并发处理:引入了基于
hs.task.new 的异步并发回调函数,避免阻塞系统。
- 错误重试与延时缓冲:引入了
hs.timer.doAfter 与重试机制缓冲对显示器的并发请求,防止执行失败。
- 系统电源事件监听:监听了系统唤醒
systemDidWake 的回调,为 USB 底层初始化预留出足够的两秒缓冲间隙。
你需要通过 macOS 的“系统信息” > “USB” 面板获取你用来做触发器识别的设备名,此处我以我的键盘接收器名称 ROG OMNI RECEIVER 作为识别触发点。
将下列完整的代码写入你的配置文件(如 init.lua 或独立的 modules/usb_script.lua):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
|
local usbWatcher = nil
local caffeinateWatcher = nil
-- Homebrew 安装路径下的 m1ddc 二进制文件位置
local m1ddc = "/opt/homebrew/Cellar/m1ddc/1.2.0/bin/m1ddc"
-- 第一台上一步拿到的显示器 UUID
local U2720_UUID = "AE60FFA9-619A-48E1-A9F5-995880A1C270"
-- 第二台上一步拿到的显示器 UUID
local U2725_UUID = "1D9A4CC2-9ACC-404C-954D-34EAEFC590A5"
-- 异步执行命令(防阻塞 macOS 主线程运行,失败时每隔 2 秒默认重试 6 次)
local MAX_RETRIES = 6
local RETRY_DELAY = 2
local function executeAsync(executable, args, retryCount)
retryCount = retryCount or 1
local argsStr = table.concat(args, " ")
local task = hs.task.new(executable, function(exitCode, stdOut, stdErr)
if exitCode ~= 0 then
if retryCount < MAX_RETRIES then
print("[USB Script] Command failed (attempt " .. retryCount .. "/" .. MAX_RETRIES .. "): " .. argsStr .. ", retrying in " .. RETRY_DELAY .. "s...")
hs.timer.doAfter(RETRY_DELAY, function()
executeAsync(executable, args, retryCount + 1)
end)
else
print("[USB Script] Command failed after " .. MAX_RETRIES .. " attempts: " .. argsStr)
print("[USB Script] stderr: " .. (stdErr or ""))
end
else
print("[USB Script] Command succeeded (attempt " .. retryCount .. "): " .. argsStr)
end
end, args)
task:start()
end
-- 检查指定的 USB 接收器名称是否在已连接阵列中存在
local function isROGReceiverConnected()
local usbDevices = hs.usb.attachedDevices()
for _, device in ipairs(usbDevices) do
if device.productName == "ROG OMNI RECEIVER" then
return true
end
end
return false
end
-- 依据连接状态分别给每个显示器的 UUID 发送信号通道切换请求
-- 输入源参考代码:
-- 25 = Thunderbolt 4 (TB4)
-- 27 = USB-C
-- 15 = DisplayPort (DP)
-- 17 = HDMI
local function switchMonitorInput()
if isROGReceiverConnected() then
print("[USB Script] ROG Receiver connected -> switching to TB4/USBC")
-- U2725QE 切换到 TB4 (25)
executeAsync(m1ddc, {"display", U2725_UUID, "set", "input", "25"})
-- U2720Q 切换到 USB-C (27)
executeAsync(m1ddc, {"display", U2720_UUID, "set", "input", "27"})
else
print("[USB Script] ROG Receiver disconnected -> switching to HDMI/DP")
-- U2725QE 切换到 DP (15)
executeAsync(m1ddc, {"display", U2725_UUID, "set", "input", "15"})
-- U2720Q 切换到 HDMI (17)
executeAsync(m1ddc, {"display", U2720_UUID, "set", "input", "17"})
end
end
-- USB 热插拔生命周期回调
local function usbDeviceCallback(data)
print("[USB Script] USB event: " .. (data["eventType"] or "unknown") .. " - " .. (data["productName"] or "unknown"))
if (data["productName"] == "ROG OMNI RECEIVER") then
-- 发现设备状态改变时,延时 2 秒切换,给予系统底层 USB 控制器驱动注册缓冲的时间
hs.timer.doAfter(2, switchMonitorInput)
end
end
-- Mac 的睡眠唤醒生命周期回调
local function caffeinateCallback(event)
-- 若刚从睡眠唤醒导致系统重载了整个 USB 拓扑,重新扫盘并下发指令
if event == hs.caffeinate.watcher.systemDidWake then
hs.timer.doAfter(2, switchMonitorInput)
end
end
-- 实例化两个监听器并挂载启动
usbWatcher = hs.usb.watcher.new(usbDeviceCallback)
usbWatcher:start()
caffeinateWatcher = hs.caffeinate.watcher.new(caffeinateCallback)
caffeinateWatcher:start()
|
如果代码存放在子目录了,记得在主配置 init.lua 最外层 require("modules.usb_script") 导入即可。上述配置都保存完成后,记得在顶部菜单栏 Hammerspoon 的图标下拉页面中点击 Reload Config 重新载入。
最后
目前在两台不同的硬件架构操作系统(Mac & Windows)切换显示器和共享外设键鼠,我只需要物理按一次廉价 USB 切换器的线控小开关就能从信号源和硬件层双向瞬间平滑过渡。在解决这个“最后一公里”的过程中重度接触并感受到了开源免费的 Hammerspoon 其 API 的强大魅力,确实是一款能高度自由定制 macOS 自动化体验的神仙工具。