脚本

自定义的显示器休眠模式

去年购买了新的 Mac mini 和 红米 G Pro 27U 显示器。

这款显示器还算不错,2000 元的价格在各方面都表现良好,对我来说有两个小不足:

  1. 唤醒有点慢,通过键盘唤醒约需 5 s
  2. 休眠以后无法使用“小爱同学”语音指令

研究了一下显示器的设置,没有保持待机不休眠的地方,只能自己动手了。

基本思路:这是一款 miniLED 显示器,在 Mac 系统开启 HDR 模式时,可以通过 Mac 系统调节显示器亮度(SDR 模式不可,只能通过显示器自身调整亮度)。miniLED 的分区控光特性决定了当亮度设置为 0 时,整块面板都不发光。
那么我需要做的就是在我不使用电脑时,把亮度设置为 0。如此的话显示器面板关闭(节能),但是系统还在运行,可以随时响应小爱同学指令。

前提条件

  1. miniLED 显示器,且已在 Mac OS - 设置 - 显示 - HDR 开启
  2. One Switch - Keep Awake
  3. Hammerspoon 软件

实现

1. 安装 Hammerspoon

  1. 官网下载安装 Hammerspoon
  2. 打开后,在菜单栏点 Hammerspoon → Open Config
  3. 会打开 ~/.hammerspoon/init.lua

也可以直接用 VS Code 等编辑器打开 ~/.hammerspoon/init.lua

2. 配置 init.lua

把下面的代码贴进 init.lua

init.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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
-- 屏保渐暗模式:锁定屏幕/进入屏保后,经过设定延迟开始逐渐降低亮度至 0
-- 退出屏保/解锁屏幕(例如按键、移动鼠标唤醒)后恢复原亮度

local brightness = require("hs.brightness")
local caffeinate = require("hs.caffeinate")
local timer = require("hs.timer")
local eventtap = require("hs.eventtap")
local host = require("hs.host")
local distributednotifications = require("hs.distributednotifications")
local pathwatcher = require("hs.pathwatcher")

-- 日志函数
local function logToFile(msg)
local f = io.open(os.getenv("HOME") .. "/.hammerspoon/dim_debug.log", "a")
if f then
f:write(os.date("%Y-%m-%d %H:%M:%S") .. " - " .. msg .. "\n")
f:close()
end
end
logToFile("------ Init lua loaded / Config reloaded ------")

-- 允许 CLI 工具安装
hs.ipc.cliInstall()

-- 自动重载配置
local function reloadConfig(files)
local doReload = false
for _,file in ipairs(files) do
if file:sub(-4) == ".lua" then
doReload = true
end
end
if doReload then
logToFile("Auto-reloading config due to file change...")
hs.reload()
end
end
-- 注意:必须将 watcher 对象存为全局变量,否则会被 Lua 垃圾回收机制 (GC) 回收,导致监听失效!
configWatcher = pathwatcher.new(os.getenv("HOME") .. "/.hammerspoon/", reloadConfig):start()

-- 配置参数
local delayBeforeDim = 0 * 60 -- 进入屏保后等待开始降亮度的延迟时间(秒)
local dimDuration = 5 -- 从当前亮度降到 0 的总时长(秒)
local steps = 2 -- 分多少步降完(越大越平滑)
local originalBrightness = nil
local dimTimer = nil
local delayTimer = nil
local idleTimer = nil
local lastIdleTime = nil
local lastLockTime = 0
local isLocked = false

-- 不同 Hammerspoon 版本可用的唤醒屏幕 API 不一样;没有就跳过,避免 timer callback 中断
local function wakeDisplayIfAvailable()
if caffeinate.wakeDisplay then
caffeinate.wakeDisplay()
elseif caffeinate.wakeSystem then
caffeinate.wakeSystem()
end
end

-- 停止所有计时器并恢复亮度
local function restoreBrightness(reason)
logToFile("restoreBrightness called. Reason: " .. tostring(reason) .. ", originalBrightness: " .. tostring(originalBrightness))
if delayTimer then
delayTimer:stop()
delayTimer = nil
end
if dimTimer then
dimTimer:stop()
dimTimer = nil
end
if idleTimer then
idleTimer:stop()
idleTimer = nil
lastIdleTime = nil
end
if originalBrightness then
brightness.set(originalBrightness)
originalBrightness = nil
end
end

-- 锁屏界面收不到按键事件,改用 idle time 变化判断是否有键盘/鼠标活动
local function startIdleWakeWatcher()
if idleTimer then
idleTimer:stop()
end

lastIdleTime = host.idleTime()
idleTimer = timer.doEvery(0.25, function()
local idleTime = host.idleTime()

-- 按键或鼠标活动会把 idle time 重置为较小的值
if lastIdleTime and idleTime + 0.5 < lastIdleTime then
logToFile("Idle timer wake detected. idleTime: " .. tostring(idleTime) .. ", lastIdleTime: " .. tostring(lastIdleTime))
-- 唤醒时重置锁屏状态
isLocked = false
restoreBrightness("idleWake")
wakeDisplayIfAvailable()
return
end

lastIdleTime = idleTime
end)
end

-- 开始渐暗
local function startDimming()
-- 如果已经在渐暗,就不重复
if dimTimer ~= nil then
logToFile("startDimming: already dimming, skipping")
return
end

originalBrightness = brightness.get()
logToFile("startDimming: originalBrightness is " .. tostring(originalBrightness))
if not originalBrightness or originalBrightness <= 0 then
return
end

local currentStep = 0
local stepInterval = dimDuration / steps
local stepDelta = originalBrightness / steps

dimTimer = timer.doEvery(stepInterval, function()
currentStep = currentStep + 1
local newValue = originalBrightness - stepDelta * currentStep
if newValue < 0 then newValue = 0 end
brightness.set(math.floor(newValue + 0.5))
logToFile("dimTimer step " .. tostring(currentStep) .. "/" .. tostring(steps) .. ", brightness set to: " .. tostring(brightness.get()))

if currentStep >= steps then
dimTimer:stop()
dimTimer = nil
startIdleWakeWatcher()
end
end)
end

-- 进入屏保时:根据延迟配置启动渐暗逻辑
local function onScreensaverStart(triggerSource)
logToFile("onScreensaverStart triggered by " .. tostring(triggerSource))

if isLocked then
logToFile("onScreensaverStart ignored: already locked")
return
end
isLocked = true
lastLockTime = timer.secondsSinceEpoch()

-- 防止重复
restoreBrightness("onScreensaverStart")

if delayBeforeDim <= 0 then
startDimming()
else
delayTimer = timer.doAfter(delayBeforeDim, function()
startDimming()
end)
end
end

-- 退出屏保时:立刻恢复亮度
local function onScreensaverStop(triggerSource)
logToFile("onScreensaverStop triggered by " .. tostring(triggerSource))
if not isLocked then
logToFile("onScreensaverStop ignored: not locked")
return
end
isLocked = false
restoreBrightness("onScreensaverStop")
end

caffeinateWatcher = caffeinate.watcher.new(function(event)
local eventMap = {
[caffeinate.watcher.screensaverDidStart] = "screensaverDidStart",
[caffeinate.watcher.screensaverDidStop] = "screensaverDidStop",
[caffeinate.watcher.screensDidLock] = "screensDidLock",
[caffeinate.watcher.screensDidUnlock] = "screensDidUnlock",
[caffeinate.watcher.systemDidWake] = "systemDidWake",
[caffeinate.watcher.screensDidWake] = "screensDidWake"
}
local eventStr = eventMap[event] or tostring(event)
logToFile("caffeinate.watcher event received: " .. eventStr)

if event == caffeinate.watcher.screensaverDidStart then
-- 进入屏保
onScreensaverStart("caffeinate_screensaverDidStart")

elseif event == caffeinate.watcher.screensaverDidStop then
-- 退出屏保(一般是你按键/移动鼠标触发)
onScreensaverStop("caffeinate_screensaverDidStop")

elseif event == caffeinate.watcher.screensDidLock then
-- 屏幕被锁定(手动锁屏或自动锁屏)
onScreensaverStart("caffeinate_screensDidLock")

elseif event == caffeinate.watcher.screensDidUnlock then
-- 解锁成功(输入密码/Touch ID 后)
onScreensaverStop("caffeinate_screensDidUnlock")

elseif event == caffeinate.watcher.systemDidWake or event == caffeinate.watcher.screensDidWake then
-- 忽略锁屏后 3 秒内的伪唤醒事件,防止锁屏过渡期间的伪唤醒事件打断降亮度
local diff = timer.secondsSinceEpoch() - lastLockTime
if isLocked and diff < 3 then
logToFile("caffeinate watcher wake event ignored (spurious lock transition): " .. eventStr)
else
-- 唤醒时重置锁屏状态
isLocked = false
restoreBrightness("caffeinate_" .. eventStr)
end
end
end)

-- Cmd + Control + Q 锁屏时,caffeinate.watcher 在部分系统上不会稳定发 screensDidLock。
-- 直接监听 macOS 的锁屏/解锁通知,让快捷键锁屏也能触发降亮度。
screenLockWatcher = distributednotifications.new(function()
logToFile("com.apple.screenIsLocked notification received")
onScreensaverStart("dist_screenIsLocked")
end, "com.apple.screenIsLocked")

screenUnlockWatcher = distributednotifications.new(function()
logToFile("com.apple.screenIsUnlocked notification received")
onScreensaverStop("dist_screenIsUnlocked")
end, "com.apple.screenIsUnlocked")

wakeTap = eventtap.new({
eventtap.event.types.keyDown,
eventtap.event.types.leftMouseDown,
eventtap.event.types.rightMouseDown,
eventtap.event.types.otherMouseDown,
eventtap.event.types.scrollWheel,
eventtap.event.types.mouseMoved,
}, function(ev)
local evType = ev:getType()
local evStr = "unknown"
if evType == eventtap.event.types.keyDown then evStr = "keyDown"
elseif evType == eventtap.event.types.leftMouseDown then evStr = "leftMouseDown"
elseif evType == eventtap.event.types.rightMouseDown then evStr = "rightMouseDown"
elseif evType == eventtap.event.types.otherMouseDown then evStr = "otherMouseDown"
elseif evType == eventtap.event.types.scrollWheel then evStr = "scrollWheel"
elseif evType == eventtap.event.types.mouseMoved then evStr = "mouseMoved"
end

-- 如果是脚本把亮度降到 0,任意键盘/鼠标活动都恢复亮度
if isLocked and originalBrightness ~= nil then
local diff = timer.secondsSinceEpoch() - lastLockTime
logToFile("wakeTap event: " .. evStr .. ", timeSinceLock: " .. tostring(diff))
-- 避免锁屏瞬间的按键释放或鼠标微调等事件与锁屏降亮度逻辑竞争(忽略锁屏后 1.5 秒内的事件)
if diff > 1.5 then
isLocked = false
restoreBrightness("wakeTap_" .. evStr)
wakeDisplayIfAvailable()
else
logToFile("wakeTap event ignored because timeSinceLock <= 1.5")
end
end

return false
end)

wakeTap:start()
caffeinateWatcher:start()
screenLockWatcher:start()
screenUnlockWatcher:start()

保存。

3. Reload Config

在菜单栏点 Hammerspoon → Reload Config。

设置完成,可以按 Control + Command + Q 进入锁屏测试效果。

结论

通过这个自定义脚本实现了我想要的显示器休眠模式。

不足:

  1. 没有进入真正意义上的休眠,可能会更耗电些(没有进行测试,大抵是不高的)

优点:

  1. 不用唤醒,也不用担心烧屏之类的问题
  2. 小爱同学随时待命(昨晚半夜喊小爱给我开空调)