|
-- This script polls Bluetooth RSSI on a short interval and locks the Mac when |
|
-- the configured iPhone has been "far" for the last N samples. Two notes about |
|
-- reliability before reading the rest of the file: |
|
-- |
|
-- 1. Recommended one-time macOS setting: |
|
-- |
|
-- defaults write org.hammerspoon.Hammerspoon NSAppSleepDisabled -bool YES |
|
-- |
|
-- Without this, macOS App Nap can suspend Hammerspoon while it is idle in |
|
-- the background, which freezes every hs.timer and hs.task inside it. The |
|
-- setting takes effect on the next Hammerspoon restart. |
|
-- |
|
-- 2. This file builds in two recovery layers, because in practice the polling |
|
-- loop occasionally goes silent on some Macs (process is alive but timers |
|
-- have stopped firing — sometimes after a sleep/wake cycle, sometimes for |
|
-- no obvious reason): |
|
-- |
|
-- a. An in-process "task heartbeat" that drives itself with chained |
|
-- hs.task /bin/sleep calls (a separate dispatch path from hs.timer). |
|
-- After every wake from sleep, and any other time performCheck has been |
|
-- silent for too long, it tears down and restarts the polling timer |
|
-- chain. This is the primary recovery mechanism — it kicks in within |
|
-- a couple of seconds of a wake. |
|
-- |
|
-- b. An external launchd watchdog (see hammerspoon_watchdog_gist.sh and |
|
-- com.hammerspoon-watchdog.plist) that watches the log file's mtime |
|
-- and restarts the entire Hammerspoon process if the in-process |
|
-- recovery somehow fails. This is the last line of defense. |
|
-- |
|
-- Both layers are needed. The Lua-only layer is faster and cleaner; the |
|
-- external layer is the only thing that can recover when the entire |
|
-- Hammerspoon runloop has stopped firing all timers and tasks. |
|
|
|
local home = os.getenv("HOME") or "/tmp" |
|
|
|
local CONFIG = { |
|
-- Fill in either the Bluetooth address, the device name, or both. |
|
targetName = "YOUR_IPHONE_NAME", |
|
targetAddress = "AA:BB:CC:DD:EE:FF", |
|
|
|
-- Optional trusted Wi-Fi networks where proximity auto-lock should stay off. |
|
-- Example: { "MyHomeWiFi" } |
|
trustedWifiSSIDs = {}, |
|
|
|
-- Slightly conservative defaults to reduce false positives in real rooms. |
|
pollIntervalSeconds = 5, |
|
awayRssiThreshold = -70, |
|
rearmRssiThreshold = -62, |
|
awaySampleCount = 4, |
|
|
|
lockAttemptCooldownSeconds = 20, |
|
unlockCooldownSeconds = 90, |
|
checkTimeoutSeconds = 15, |
|
|
|
-- Safer default for public distribution: missing RSSI alone does not lock. |
|
-- Note: this only applies to the "device is completely missing from the scan" |
|
-- case. The separate "device visible but no RSSI field" case (a known macOS |
|
-- system_profiler quirk) is always skipped, regardless of this flag — see |
|
-- evaluateDistance below. |
|
missingCountsAsAway = false, |
|
|
|
-- If this many consecutive startup samples come back "device visible, no RSSI |
|
-- field", emit a one-time warning. Some macOS builds stop exposing RSSI via |
|
-- system_profiler SPBluetoothDataType entirely; in that state proximity |
|
-- detection cannot work at all, and it is better to tell the user loudly than |
|
-- to silently do nothing. |
|
rssiHealthWarningThreshold = 15, |
|
|
|
notify = true, |
|
debug = false, |
|
logFile = home .. "/Library/Logs/iphone-proximity-lock.log", |
|
logSessionProperties = false, |
|
|
|
-- `hs.caffeinate.lockScreen()` uses a private API. If macOS stops honoring it, |
|
-- optionally fall back to starting the screensaver after a short delay. |
|
allowLockFallbackToScreensaver = true, |
|
lockFallbackDelaySeconds = 3, |
|
} |
|
|
|
local log = hs.logger.new("iphone-lock", CONFIG.debug and "debug" or "info") |
|
local caffeinateWatcher = nil |
|
local pollTimer = nil |
|
local heartbeatTimer = nil |
|
local taskHeartbeatTask = nil |
|
local taskHeartbeatGeneration = 0 |
|
local activeTasks = {} |
|
local state = nil |
|
local scriptStartEpoch = nil |
|
|
|
-- Forward declarations so the recovery helpers and the wake handler can call |
|
-- each other regardless of definition order below. |
|
local startPollTimer |
|
local startHeartbeat |
|
local startTaskHeartbeat |
|
local performCheck |
|
|
|
-- Recovery tuning. The script declares performCheck "silent" if it has not run |
|
-- for HEARTBEAT_SILENCE_RECOVERY_SECONDS, and the heartbeat itself ticks every |
|
-- HEARTBEAT_INTERVAL_SECONDS so the worst-case detection latency is roughly |
|
-- HEARTBEAT_INTERVAL_SECONDS + HEARTBEAT_SILENCE_RECOVERY_SECONDS. |
|
local HEARTBEAT_INTERVAL_SECONDS = 10 |
|
local HEARTBEAT_SILENCE_RECOVERY_SECONDS = 15 |
|
|
|
if CONFIG.rearmRssiThreshold <= CONFIG.awayRssiThreshold then |
|
CONFIG.rearmRssiThreshold = CONFIG.awayRssiThreshold + 5 |
|
end |
|
|
|
local function trim(value) |
|
if type(value) ~= "string" then |
|
return value |
|
end |
|
|
|
return value:match("^%s*(.-)%s*$") |
|
end |
|
|
|
local function normalizeAddress(value) |
|
if type(value) ~= "string" then |
|
return nil |
|
end |
|
|
|
local normalized = value:gsub("[^%x]", ""):upper() |
|
if normalized == "" then |
|
return nil |
|
end |
|
|
|
return normalized |
|
end |
|
|
|
local function hasConfiguredAddress() |
|
return normalizeAddress(CONFIG.targetAddress) ~= nil |
|
and normalizeAddress(CONFIG.targetAddress) ~= "AABBCCDDEEFF" |
|
end |
|
|
|
local function hasConfiguredName() |
|
return trim(CONFIG.targetName) ~= nil |
|
and trim(CONFIG.targetName) ~= "" |
|
and trim(CONFIG.targetName) ~= "YOUR_IPHONE_NAME" |
|
end |
|
|
|
local function targetMatches(device) |
|
if not device then |
|
return false |
|
end |
|
|
|
if hasConfiguredAddress() and device.address == normalizeAddress(CONFIG.targetAddress) then |
|
return true |
|
end |
|
|
|
if hasConfiguredName() and device.name == trim(CONFIG.targetName) then |
|
return true |
|
end |
|
|
|
return false |
|
end |
|
|
|
local function notify(title, body) |
|
if not CONFIG.notify then |
|
return |
|
end |
|
|
|
hs.notify.new({ title = title, informativeText = body }):send() |
|
end |
|
|
|
local function appendLog(message) |
|
if not CONFIG.logFile then |
|
return |
|
end |
|
|
|
local file = io.open(CONFIG.logFile, "a") |
|
if not file then |
|
return |
|
end |
|
|
|
file:write(os.date("%Y-%m-%d %H:%M:%S "), message, "\n") |
|
file:close() |
|
end |
|
|
|
local function firstString(node, keys) |
|
for _, key in ipairs(keys) do |
|
local value = node[key] |
|
if type(value) == "string" and trim(value) ~= "" then |
|
return trim(value) |
|
end |
|
end |
|
|
|
return nil |
|
end |
|
|
|
local function firstNumber(node, keys) |
|
for _, key in ipairs(keys) do |
|
local value = node[key] |
|
if type(value) == "number" then |
|
return value |
|
end |
|
if type(value) == "string" and value:match("^-?%d+$") then |
|
return tonumber(value) |
|
end |
|
end |
|
|
|
return nil |
|
end |
|
|
|
local function candidateFromNode(node) |
|
if type(node) ~= "table" then |
|
return nil |
|
end |
|
|
|
local candidate = { |
|
name = firstString(node, { |
|
"_name", |
|
"name", |
|
"device_name", |
|
"device_title", |
|
"local_name", |
|
"device_local_name", |
|
}), |
|
address = normalizeAddress(firstString(node, { |
|
"device_address", |
|
"address", |
|
"mac_address", |
|
"bd_addr", |
|
"Address", |
|
})), |
|
rssi = firstNumber(node, { |
|
"device_rssi", |
|
"rssi", |
|
"RSSI", |
|
}), |
|
} |
|
|
|
if candidate.name or candidate.address or candidate.rssi then |
|
return candidate |
|
end |
|
|
|
return nil |
|
end |
|
|
|
local function parseBluetoothJsonNode(node) |
|
if type(node) ~= "table" then |
|
return nil |
|
end |
|
|
|
local candidate = candidateFromNode(node) |
|
if targetMatches(candidate) then |
|
return candidate |
|
end |
|
|
|
for _, value in pairs(node) do |
|
local match = parseBluetoothJsonNode(value) |
|
if match then |
|
return match |
|
end |
|
end |
|
|
|
return nil |
|
end |
|
|
|
local function parseBluetoothJson(output) |
|
local ok, decoded = pcall(hs.json.decode, output) |
|
if not ok or type(decoded) ~= "table" then |
|
return nil |
|
end |
|
|
|
return parseBluetoothJsonNode(decoded) |
|
end |
|
|
|
local function parseBluetoothText(output) |
|
local current = nil |
|
|
|
for rawLine in output:gmatch("[^\r\n]+") do |
|
local indent, content = rawLine:match("^(%s*)(.-)%s*$") |
|
local depth = #indent |
|
|
|
if current and depth <= current.depth then |
|
if targetMatches(current) then |
|
return current |
|
end |
|
current = nil |
|
end |
|
|
|
local heading = content:match("^(.-):$") |
|
if heading and depth >= 6 and heading ~= "Bluetooth Controller" and heading ~= "Connected" and heading ~= "Not Connected" then |
|
current = { name = trim(heading), depth = depth } |
|
elseif current then |
|
local lower = content:lower() |
|
local address = lower:match("^address:%s*(.+)$") |
|
if address then |
|
current.address = normalizeAddress(address) |
|
end |
|
|
|
local rssi = lower:match("^rssi:%s*(-?%d+)$") |
|
if rssi then |
|
current.rssi = tonumber(rssi) |
|
end |
|
end |
|
end |
|
|
|
if current and targetMatches(current) then |
|
return current |
|
end |
|
|
|
return nil |
|
end |
|
|
|
local function parseBluetoothSnapshot(output, format) |
|
if format == "json" then |
|
local parsed = parseBluetoothJson(output) |
|
if parsed then |
|
return parsed |
|
end |
|
end |
|
|
|
return parseBluetoothText(output) |
|
end |
|
|
|
state = { |
|
isAway = false, |
|
checkRunning = false, |
|
checkToken = 0, |
|
checkStartedAt = 0, |
|
lastCheckAt = 0, |
|
screenLocked = false, |
|
lockPending = false, |
|
lockPendingSince = 0, |
|
autoLockCycleActive = false, |
|
cooldownUntil = 0, |
|
cooldownReason = nil, |
|
recentSamples = {}, |
|
trustedWifiSSID = nil, |
|
sawAnyRssi = false, |
|
startupMissingRssiStreak = 0, |
|
rssiHealthWarned = false, |
|
} |
|
|
|
local function describeSignal(device) |
|
if not device then |
|
return "not visible" |
|
end |
|
|
|
local label = device.name or "target device" |
|
if device.rssi then |
|
return string.format("%s RSSI %d", label, device.rssi) |
|
end |
|
|
|
return string.format("%s visible without RSSI", label) |
|
end |
|
|
|
local function startCooldown(seconds, reason) |
|
if not seconds or seconds <= 0 then |
|
state.cooldownUntil = 0 |
|
state.cooldownReason = nil |
|
return |
|
end |
|
|
|
state.cooldownUntil = hs.timer.secondsSinceEpoch() + seconds |
|
state.cooldownReason = reason |
|
appendLog(string.format("Cooldown started: %s for %ss", reason, seconds)) |
|
end |
|
|
|
local function clearAwayState(device, shouldNotify) |
|
if state.isAway then |
|
local message = device and ("Back in range: " .. describeSignal(device)) or "Away state cleared" |
|
log.i(message) |
|
appendLog(message) |
|
if shouldNotify then |
|
notify("iPhone proximity", "iPhone looks close again. Continuing to monitor.") |
|
end |
|
end |
|
|
|
state.isAway = false |
|
state.recentSamples = {} |
|
end |
|
|
|
local function resetPollingState(reason) |
|
state.checkToken = state.checkToken + 1 |
|
state.checkRunning = false |
|
state.checkStartedAt = 0 |
|
for _, task in pairs(activeTasks) do |
|
pcall(function() |
|
task:terminate() |
|
end) |
|
end |
|
state.lockPending = false |
|
state.lockPendingSince = 0 |
|
state.autoLockCycleActive = false |
|
state.trustedWifiSSID = nil |
|
activeTasks = {} |
|
clearAwayState(nil, false) |
|
appendLog("Polling state reset: " .. reason) |
|
end |
|
|
|
-- Lua's `table.insert(t, nil)` is a no-op, so a missing RSSI would silently |
|
-- drop out of the window and break the "N recent samples" invariant. Store a |
|
-- `false` sentinel for missing values and translate it back when reading. |
|
local function pushSample(value) |
|
local stored = value |
|
if stored == nil then |
|
stored = false |
|
end |
|
state.recentSamples[#state.recentSamples + 1] = stored |
|
while #state.recentSamples > CONFIG.awaySampleCount do |
|
table.remove(state.recentSamples, 1) |
|
end |
|
end |
|
|
|
local function currentWifiSSID() |
|
local ok, ssid = pcall(function() |
|
return hs.wifi and hs.wifi.currentNetwork and hs.wifi.currentNetwork() or nil |
|
end) |
|
if ok and type(ssid) == "string" and trim(ssid) ~= "" then |
|
return trim(ssid) |
|
end |
|
|
|
local output = hs.execute("/usr/sbin/ipconfig getsummary en0 2>/dev/null") |
|
if not output or output == "" then |
|
return nil |
|
end |
|
|
|
return trim(output:match("\n%s*SSID%s*:%s*([^\n]+)")) |
|
end |
|
|
|
local function trustedWifiSSID() |
|
local currentSSID = currentWifiSSID() |
|
if not currentSSID then |
|
return nil |
|
end |
|
|
|
for _, ssid in ipairs(CONFIG.trustedWifiSSIDs or {}) do |
|
if trim(ssid) == currentSSID then |
|
return currentSSID |
|
end |
|
end |
|
|
|
return nil |
|
end |
|
|
|
local function recentSamplesLabel() |
|
local parts = {} |
|
for _, value in ipairs(state.recentSamples) do |
|
table.insert(parts, value == false and "n/a" or tostring(value)) |
|
end |
|
|
|
return string.format("recent %d [%s]", CONFIG.awaySampleCount, table.concat(parts, ", ")) |
|
end |
|
|
|
local function recentWindowIsAway() |
|
if #state.recentSamples < CONFIG.awaySampleCount then |
|
return false |
|
end |
|
|
|
for _, value in ipairs(state.recentSamples) do |
|
if value == false then |
|
if not CONFIG.missingCountsAsAway then |
|
return false |
|
end |
|
elseif value > CONFIG.awayRssiThreshold then |
|
return false |
|
end |
|
end |
|
|
|
return true |
|
end |
|
|
|
local function recentWindowIsBackInRange() |
|
if #state.recentSamples < CONFIG.awaySampleCount then |
|
return false |
|
end |
|
|
|
for _, value in ipairs(state.recentSamples) do |
|
if value == false or value < CONFIG.rearmRssiThreshold then |
|
return false |
|
end |
|
end |
|
|
|
return true |
|
end |
|
|
|
local function lockScreen() |
|
log.i("Requesting macOS lock") |
|
appendLog("Requesting macOS lock via hs.caffeinate.lockScreen()") |
|
|
|
local ok, err = pcall(hs.caffeinate.lockScreen) |
|
if not ok then |
|
appendLog("lockScreen threw an error: " .. tostring(err)) |
|
end |
|
|
|
if CONFIG.allowLockFallbackToScreensaver then |
|
hs.timer.doAfter(CONFIG.lockFallbackDelaySeconds, function() |
|
if state.screenLocked then |
|
return |
|
end |
|
|
|
appendLog("Lock event not observed; starting screensaver fallback") |
|
hs.caffeinate.startScreensaver() |
|
end) |
|
end |
|
|
|
if CONFIG.logSessionProperties then |
|
hs.timer.doAfter(2, function() |
|
local props = hs.caffeinate.sessionProperties() |
|
appendLog("Session properties after lock request: " .. hs.inspect(props)) |
|
end) |
|
end |
|
end |
|
|
|
local function finishTask(taskId) |
|
activeTasks[taskId] = nil |
|
end |
|
|
|
local function runBluetoothSnapshot(callback) |
|
local taskId = tostring(hs.timer.secondsSinceEpoch()) .. "-" .. tostring(math.random(100000, 999999)) |
|
local format = "json" |
|
|
|
local function makeTask(args, taskFormat) |
|
return hs.task.new("/usr/sbin/system_profiler", function(exitCode, stdOut, stdErr) |
|
finishTask(taskId) |
|
|
|
if exitCode ~= 0 then |
|
callback(nil, stdErr ~= "" and stdErr or ("system_profiler exited with " .. tostring(exitCode))) |
|
return |
|
end |
|
|
|
callback(parseBluetoothSnapshot(stdOut, taskFormat), nil) |
|
end, args) |
|
end |
|
|
|
local task = makeTask({ "-json", "SPBluetoothDataType" }, format) |
|
if not task then |
|
format = "text" |
|
task = makeTask({ "SPBluetoothDataType" }, format) |
|
end |
|
|
|
if not task then |
|
callback(nil, "failed to create system_profiler task") |
|
return |
|
end |
|
|
|
activeTasks[taskId] = task |
|
task:start() |
|
end |
|
|
|
local function evaluateDistance(device) |
|
local now = hs.timer.secondsSinceEpoch() |
|
local bypassSSID = trustedWifiSSID() |
|
|
|
if bypassSSID then |
|
if state.trustedWifiSSID ~= bypassSSID then |
|
appendLog("Trusted Wi-Fi bypass active: " .. bypassSSID) |
|
end |
|
state.trustedWifiSSID = bypassSSID |
|
state.lockPending = false |
|
state.lockPendingSince = 0 |
|
clearAwayState(nil, false) |
|
return "trusted wifi [" .. bypassSSID .. "]" |
|
end |
|
|
|
if state.trustedWifiSSID then |
|
appendLog("Trusted Wi-Fi bypass ended: " .. state.trustedWifiSSID) |
|
state.trustedWifiSSID = nil |
|
end |
|
|
|
-- Two "missing" cases to keep distinct: |
|
-- (1) device == nil: the device is not in the scan at all. This is a real |
|
-- absence signal; push a sentinel so missingCountsAsAway can act on it. |
|
-- (2) device is visible but device.rssi == nil: a known macOS quirk where |
|
-- system_profiler SPBluetoothDataType omits the RSSI field for a |
|
-- still-present device. This is NOT a distance signal, so we skip the |
|
-- sample entirely — the window keeps its last N *valid* RSSI values. |
|
if device and device.rssi == nil then |
|
if not state.sawAnyRssi then |
|
state.startupMissingRssiStreak = state.startupMissingRssiStreak + 1 |
|
if state.startupMissingRssiStreak == CONFIG.rssiHealthWarningThreshold and not state.rssiHealthWarned then |
|
state.rssiHealthWarned = true |
|
local msg = string.format( |
|
"Warning: %d consecutive samples have no RSSI field. This macOS build may not expose RSSI via system_profiler SPBluetoothDataType. Proximity detection will not work on this Mac.", |
|
CONFIG.rssiHealthWarningThreshold) |
|
appendLog(msg) |
|
notify("iPhone proximity", "Warning: this Mac is not exposing Bluetooth RSSI. Proximity detection cannot work.") |
|
end |
|
end |
|
return recentSamplesLabel() .. " (rssi-missing skipped)" |
|
end |
|
|
|
local rssi = device and device.rssi or nil |
|
if rssi ~= nil then |
|
state.sawAnyRssi = true |
|
state.startupMissingRssiStreak = 0 |
|
end |
|
pushSample(rssi) |
|
local awayNow = recentWindowIsAway() |
|
local backInRangeNow = recentWindowIsBackInRange() |
|
local sampleLabel = recentSamplesLabel() |
|
|
|
if state.isAway and backInRangeNow then |
|
appendLog("Back in range samples: " .. sampleLabel) |
|
clearAwayState(device, true) |
|
return sampleLabel |
|
end |
|
|
|
if not state.isAway then |
|
if not awayNow then |
|
return sampleLabel |
|
end |
|
|
|
state.isAway = true |
|
log.i("Away threshold crossed after recent samples: " .. describeSignal(device)) |
|
appendLog("Away threshold crossed after " .. sampleLabel .. ": " .. describeSignal(device)) |
|
end |
|
|
|
local inCooldown = now < state.cooldownUntil |
|
|
|
if state.lockPending and now - state.lockPendingSince >= 10 then |
|
state.lockPending = false |
|
state.lockPendingSince = 0 |
|
appendLog("Clearing stale lockPending state") |
|
end |
|
|
|
if inCooldown or state.screenLocked or state.lockPending then |
|
return sampleLabel |
|
end |
|
|
|
if awayNow then |
|
state.lockPending = true |
|
state.lockPendingSince = now |
|
state.autoLockCycleActive = true |
|
startCooldown(CONFIG.lockAttemptCooldownSeconds, "after_auto_lock_request") |
|
appendLog("Away across " .. sampleLabel .. ", locking") |
|
notify("iPhone proximity", "iPhone looks far away. Locking this Mac.") |
|
lockScreen() |
|
end |
|
|
|
return sampleLabel |
|
end |
|
|
|
performCheck = function() |
|
state.lastCheckAt = hs.timer.secondsSinceEpoch() |
|
|
|
if state.checkRunning then |
|
local now = hs.timer.secondsSinceEpoch() |
|
if state.checkStartedAt > 0 and now - state.checkStartedAt >= CONFIG.checkTimeoutSeconds then |
|
appendLog(string.format("Check timed out after %ss; resetting", CONFIG.checkTimeoutSeconds)) |
|
resetPollingState("check_timeout") |
|
else |
|
log.df("Skipping check because previous task is still running") |
|
return |
|
end |
|
end |
|
|
|
state.checkToken = state.checkToken + 1 |
|
local checkToken = state.checkToken |
|
state.checkRunning = true |
|
state.checkStartedAt = hs.timer.secondsSinceEpoch() |
|
|
|
runBluetoothSnapshot(function(device, err) |
|
if checkToken ~= state.checkToken then |
|
appendLog("Ignoring stale Bluetooth snapshot callback") |
|
return |
|
end |
|
|
|
state.checkRunning = false |
|
state.checkStartedAt = 0 |
|
|
|
if err then |
|
log.e(err) |
|
appendLog("Error: " .. tostring(err)) |
|
return |
|
end |
|
|
|
local sampleLabel = evaluateDistance(device) |
|
log.df("Snapshot: %s | %s", describeSignal(device), sampleLabel) |
|
appendLog("Snapshot: " .. describeSignal(device) .. " | " .. sampleLabel) |
|
end) |
|
end |
|
|
|
-- --------------------------------------------------------------------------- |
|
-- Recovery layer: in-process timer / task heartbeats |
|
-- --------------------------------------------------------------------------- |
|
-- |
|
-- The polling loop is driven by an hs.timer. On some Macs that timer can stop |
|
-- firing after a sleep/wake cycle (or, more rarely, mid-session) while the |
|
-- Hammerspoon process itself stays alive. None of the watcher events are |
|
-- guaranteed to fire in that scenario, so the script needs its own way to |
|
-- notice the silence and recover. |
|
-- |
|
-- Two heartbeats run in parallel: |
|
-- |
|
-- * heartbeatTimer is an hs.timer.doEvery loop. If it survives the same |
|
-- event that killed pollTimer, it will notice the silence first. |
|
-- |
|
-- * taskHeartbeatTask is a chained hs.task running /bin/sleep. This is a |
|
-- completely separate dispatch path from hs.timer, so it can recover |
|
-- cases where every hs.timer in the process has gone silent. |
|
-- |
|
-- Both check `state.lastCheckAt` against HEARTBEAT_SILENCE_RECOVERY_SECONDS |
|
-- and, if performCheck has been silent for too long, tear down and restart |
|
-- the polling timer chain. |
|
|
|
startPollTimer = function() |
|
if pollTimer then |
|
pcall(function() pollTimer:stop() end) |
|
pollTimer = nil |
|
end |
|
pollTimer = hs.timer.doEvery(CONFIG.pollIntervalSeconds, performCheck) |
|
appendLog("pollTimer (re)started") |
|
end |
|
|
|
startHeartbeat = function() |
|
if heartbeatTimer then |
|
pcall(function() heartbeatTimer:stop() end) |
|
heartbeatTimer = nil |
|
end |
|
heartbeatTimer = hs.timer.doEvery(HEARTBEAT_INTERVAL_SECONDS, function() |
|
local now = hs.timer.secondsSinceEpoch() |
|
local silence = now - (state.lastCheckAt or 0) |
|
local uptime = now - (scriptStartEpoch or now) |
|
appendLog(string.format( |
|
"Heartbeat: uptime=%.1fs silence=%.1fs checkRunning=%s", |
|
uptime, silence, tostring(state.checkRunning))) |
|
if silence > HEARTBEAT_SILENCE_RECOVERY_SECONDS then |
|
appendLog(string.format( |
|
"Heartbeat: performCheck silent for %.1fs, restarting pollTimer", silence)) |
|
resetPollingState("heartbeat_restart") |
|
startPollTimer() |
|
performCheck() |
|
end |
|
end) |
|
appendLog("heartbeatTimer started") |
|
end |
|
|
|
startTaskHeartbeat = function() |
|
taskHeartbeatGeneration = taskHeartbeatGeneration + 1 |
|
local myGen = taskHeartbeatGeneration |
|
if taskHeartbeatTask then |
|
pcall(function() taskHeartbeatTask:terminate() end) |
|
taskHeartbeatTask = nil |
|
end |
|
|
|
local function tick() |
|
if myGen ~= taskHeartbeatGeneration then |
|
return |
|
end |
|
|
|
local now = hs.timer.secondsSinceEpoch() |
|
local silence = now - (state.lastCheckAt or 0) |
|
local uptime = now - (scriptStartEpoch or now) |
|
appendLog(string.format( |
|
"TaskHeartbeat: uptime=%.1fs silence=%.1fs checkRunning=%s", |
|
uptime, silence, tostring(state.checkRunning))) |
|
|
|
if silence > HEARTBEAT_SILENCE_RECOVERY_SECONDS then |
|
appendLog(string.format( |
|
"TaskHeartbeat: performCheck silent for %.1fs, restarting timers", silence)) |
|
resetPollingState("task_heartbeat_restart") |
|
if startPollTimer then startPollTimer() end |
|
if startHeartbeat then startHeartbeat() end |
|
performCheck() |
|
end |
|
|
|
if myGen ~= taskHeartbeatGeneration then |
|
return |
|
end |
|
|
|
local nextTask = hs.task.new("/bin/sleep", function() |
|
if myGen == taskHeartbeatGeneration then |
|
tick() |
|
end |
|
end, { tostring(HEARTBEAT_INTERVAL_SECONDS) }) |
|
if nextTask then |
|
taskHeartbeatTask = nextTask |
|
nextTask:start() |
|
else |
|
appendLog("TaskHeartbeat: failed to create next /bin/sleep task") |
|
end |
|
end |
|
|
|
local first = hs.task.new("/bin/sleep", function() |
|
if myGen == taskHeartbeatGeneration then |
|
tick() |
|
end |
|
end, { tostring(HEARTBEAT_INTERVAL_SECONDS) }) |
|
if first then |
|
taskHeartbeatTask = first |
|
first:start() |
|
appendLog("taskHeartbeat started (gen " .. tostring(myGen) .. ")") |
|
else |
|
appendLog("TaskHeartbeat: failed to create initial /bin/sleep task") |
|
end |
|
end |
|
|
|
if not hasConfiguredAddress() and not hasConfiguredName() then |
|
appendLog("Watcher not started: set targetName or targetAddress first") |
|
notify("iPhone proximity", "Set targetName or targetAddress in the config before starting.") |
|
return |
|
end |
|
|
|
appendLog("Starting iPhone proximity watcher") |
|
notify("iPhone proximity", "Started iPhone proximity watcher.") |
|
|
|
caffeinateWatcher = hs.caffeinate.watcher.new(function(event) |
|
local names = { |
|
[hs.caffeinate.watcher.systemWillSleep] = "systemWillSleep", |
|
[hs.caffeinate.watcher.systemDidWake] = "systemDidWake", |
|
[hs.caffeinate.watcher.screensDidLock] = "screensDidLock", |
|
[hs.caffeinate.watcher.screensDidUnlock] = "screensDidUnlock", |
|
[hs.caffeinate.watcher.sessionDidResignActive] = "sessionDidResignActive", |
|
[hs.caffeinate.watcher.sessionDidBecomeActive] = "sessionDidBecomeActive", |
|
[hs.caffeinate.watcher.screensaverDidStart] = "screensaverDidStart", |
|
[hs.caffeinate.watcher.screensaverDidStop] = "screensaverDidStop", |
|
} |
|
|
|
appendLog("Caffeinate event: " .. (names[event] or tostring(event))) |
|
|
|
if event == hs.caffeinate.watcher.systemWillSleep then |
|
resetPollingState("system_will_sleep") |
|
return |
|
end |
|
|
|
if event == hs.caffeinate.watcher.systemDidWake then |
|
-- On Macs where systemDidWake actually fires, force a clean restart of |
|
-- the entire timer chain. The taskHeartbeat path will also catch this |
|
-- on Macs where systemDidWake never fires after a clamshell/dark sleep. |
|
resetPollingState("system_did_wake") |
|
hs.timer.doAfter(2, function() |
|
startPollTimer() |
|
startHeartbeat() |
|
startTaskHeartbeat() |
|
performCheck() |
|
end) |
|
return |
|
end |
|
|
|
if event == hs.caffeinate.watcher.screensDidLock then |
|
state.screenLocked = true |
|
state.lockPending = false |
|
state.lockPendingSince = 0 |
|
return |
|
end |
|
|
|
if event == hs.caffeinate.watcher.screensDidUnlock then |
|
local wasAutoLocked = state.screenLocked or state.autoLockCycleActive or state.lockPending |
|
state.screenLocked = false |
|
state.lockPending = false |
|
state.lockPendingSince = 0 |
|
state.autoLockCycleActive = false |
|
|
|
if wasAutoLocked then |
|
clearAwayState(nil, false) |
|
startCooldown(CONFIG.unlockCooldownSeconds, "after_manual_unlock") |
|
end |
|
end |
|
end) |
|
caffeinateWatcher:start() |
|
|
|
scriptStartEpoch = hs.timer.secondsSinceEpoch() |
|
state.lastCheckAt = scriptStartEpoch |
|
performCheck() |
|
startPollTimer() |
|
startHeartbeat() |
|
startTaskHeartbeat() |