Files
headsetcontrol-noctalia-plugin/headsetcontrol/lib/hsc.luau
T

288 lines
8.5 KiB
Luau

-- Shared HeadsetControl CLI wrapper. Mock mode simulates an Audeze Maxwell.
local M = {}
local CAP_KEY = {
CAP_SIDETONE = "sidetone",
CAP_BATTERY_STATUS = "battery",
CAP_INACTIVE_TIME = "inactive_time",
CAP_CHATMIX_STATUS = "chatmix",
CAP_VOICE_PROMPTS = "voice_prompts",
CAP_EQUALIZER_PRESET = "equalizer_preset",
CAP_VOLUME_LIMITER = "volume_limiter",
CAP_NOISE_FILTER = "noise_filter",
}
local config = { device = "", mock = false, wake_sink = "" }
local lowStreak = 0
function M.init(cfg)
config.device = cfg.device or ""
config.mock = cfg.mock or false
config.wake_sink = cfg.wake_sink or ""
lowStreak = 0
end
function M.isMock()
return config.mock
end
local function deviceArgs()
if config.device == "" then
return {}
end
return { "-d", config.device }
end
local function decodeJson(raw)
if raw == nil or raw == "" then
return nil
end
local ok, data = pcall(noctalia.json.decode, raw)
if not ok or type(data) ~= "table" then
return nil
end
return data
end
local MOCK_CAPS = {
name = "Audeze Maxwell",
product = "Audeze Maxwell Dongle",
vendor = "Audeze LLC",
caps = {
sidetone = true, battery = true, inactive_time = true, chatmix = true,
voice_prompts = true, equalizer_preset = true, volume_limiter = true,
noise_filter = true,
},
equalizerPresets = 10,
}
-- Fetches device capabilities once. cb(info) or cb(nil) on failure.
function M.fetchCapabilities(cb)
if config.mock then
cb(MOCK_CAPS)
return
end
local argv = { "headsetcontrol" }
for _, a in ipairs(deviceArgs()) do table.insert(argv, a) end
table.insert(argv, "--capabilities")
table.insert(argv, "-o")
table.insert(argv, "json")
noctalia.runAsync(argv, function(res)
if res.exitCode ~= 0 then
cb(nil)
return
end
local data = decodeJson(res.stdout)
if data == nil or data.devices == nil or #data.devices == 0 then
cb(nil)
return
end
local dev = data.devices[1]
local caps = {}
for _, c in ipairs(dev.capabilities or {}) do
local key = CAP_KEY[c]
if key then caps[key] = true end
end
cb({
name = dev.device or "Unknown",
product = dev.product,
vendor = dev.vendor,
caps = caps,
equalizerPresets = dev.equalizer_presets_count or 0,
})
end)
end
-- Mock battery walks down 88 -> 10 then resets, exercising all color bands + warning.
local mockBattery = 88
local function mockChatmix()
if mockBattery > 60 then return 40 end
if mockBattery > 30 then return -40 end
return 0
end
local function nextMockStatus()
if mockBattery < 10 then mockBattery = 88 end
local level = mockBattery
mockBattery = mockBattery - 1
return {
battery = { status = "BATTERY_NORMAL", level = level },
chatmix = mockChatmix(),
}
end
-- Fetches battery + chatmix. cb(status) or cb(nil) on failure/unavailable.
function M.fetchStatus(cb)
if config.mock then
cb(nextMockStatus())
return
end
local argv = { "headsetcontrol" }
for _, a in ipairs(deviceArgs()) do table.insert(argv, a) end
table.insert(argv, "-b")
table.insert(argv, "-m")
table.insert(argv, "-o")
table.insert(argv, "json")
noctalia.runAsync(argv, function(res)
if res.exitCode ~= 0 then
cb(nil)
return
end
local data = decodeJson(res.stdout)
if data == nil or data.devices == nil or #data.devices == 0 then
cb(nil)
return
end
local dev = data.devices[1]
if dev.status ~= "success" then
cb(nil)
return
end
cb({
battery = dev.battery or { status = "BATTERY_UNAVAILABLE", level = -1 },
chatmix = dev.chatmix or 0,
})
end)
end
-- Runs a command as an argv array: sendCommand({"-s", "64"}, cb).
-- cb(result) receives { exitCode, stdout, stderr, ... }; callers must check result.exitCode.
function M.sendCommand(args, cb)
if config.mock then
if cb then cb({ exitCode = 0, stdout = "", stderr = "" }) end
return
end
local argv = { "headsetcontrol" }
for _, a in ipairs(deviceArgs()) do table.insert(argv, a) end
if args ~= nil then
for _, a in ipairs(args) do table.insert(argv, a) end
end
noctalia.runAsync(argv, cb or function() end)
end
-- Plays the bundled wake tone to bring the headset out of idle.
-- cb(ok) with ok=true on success. In mock mode, simulates success without playing.
function M.wake(cb)
if config.mock then
if cb then cb(true) end
return
end
local dir = noctalia.pluginDir()
if dir == nil or dir == "" then
noctalia.log("wake tone: plugin dir unavailable")
if cb then cb(false) end
return
end
local path = dir .. "/sounds/wake.wav"
if not noctalia.fileExists(path) then
noctalia.log("wake tone missing: " .. path)
if cb then cb(false) end
return
end
local argv
if noctalia.commandExists("paplay") then
argv = { "paplay" }
if config.wake_sink ~= "" then
table.insert(argv, "--device=" .. config.wake_sink)
end
table.insert(argv, path)
else
argv = { "pw-play" }
if config.wake_sink ~= "" then
table.insert(argv, "-d")
table.insert(argv, config.wake_sink)
end
table.insert(argv, path)
if config.wake_sink ~= "" then
noctalia.log("paplay not found, falling back to pw-play with sink " .. config.wake_sink)
else
noctalia.log("paplay not found, falling back to pw-play")
end
end
noctalia.runAsync(argv, function(res)
if res.exitCode ~= 0 then
noctalia.log("wake tone playback failed, exit " .. tostring(res.exitCode) .. ": " .. string.sub(res.stderr or "", 1, 200))
end
if cb then cb(res.exitCode == 0) end
end)
end
-- Checks whether the headset is connected. cb(connected:boolean).
-- `headsetcontrol --connected` prints `true` or `false` (verified live).
-- In mock mode, reports true when the mock battery level is above 30, else false (mock connected follows the mock battery walk: true while level > 30).
function M.fetchConnected(cb)
if config.mock then
if cb then cb(mockBattery > 30) end
return
end
local argv = { "headsetcontrol" }
for _, a in ipairs(deviceArgs()) do table.insert(argv, a) end
table.insert(argv, "--connected")
noctalia.runAsync(argv, function(res)
if res.exitCode ~= 0 then
if cb then cb(false) end
return
end
if cb then cb(noctalia.string.trim(res.stdout or "") == "true") end
end)
end
-- Validates and normalizes a battery table to {status, level} with a
-- numeric level. Anything malformed (non-table, missing/non-numeric
-- level) becomes unavailable. No streak side effects; safe for one-shot
-- publishers like the panel wake read.
function M.normalizeBattery(battery)
if type(battery) ~= "table" then
return { status = "BATTERY_UNAVAILABLE", level = -1 }
end
local level = tonumber(battery.level)
if level == nil then
return { status = "BATTERY_UNAVAILABLE", level = -1 }
end
return { status = battery.status, level = level }
end
-- Confirms below-threshold readings: a lone reading at or below `low`
-- is normalized to unavailable (no false widget value or warning); two
-- in a row pass through as genuinely low. Readings in (low, low+5] show
-- as-is without touching the streak (threshold hover); readings above
-- low+5 reset it. Fetch failures (nil/malformed input) carry no
-- information and leave the streak untouched; an explicit device
-- unavailable (level < 0) resets it. Single owner is the service entry.
function M.stableBattery(battery, low)
if battery == nil or type(battery) ~= "table" then
return { status = "BATTERY_UNAVAILABLE", level = -1 }
end
local level = tonumber(battery.level)
if level == nil then
return { status = "BATTERY_UNAVAILABLE", level = -1 }
end
local threshold = tonumber(low) or 20
if level < 0 then
lowStreak = 0
return { status = "BATTERY_UNAVAILABLE", level = -1 }
end
if level <= threshold then
lowStreak = lowStreak + 1
if lowStreak >= 2 then
return { status = battery.status, level = level }
end
return { status = "BATTERY_UNAVAILABLE", level = -1 }
end
if level > threshold + 5 then
lowStreak = 0
end
return { status = battery.status, level = level }
end
-- chatmix direction is device-ambiguous. Negative = game side on signed-convention
-- devices; positive is shown raw because center/direction conventions vary by device
-- (e.g. Maxwell uses unsigned 0-128 centered at 64).
function M.chatmixText(value)
if value < 0 then return tostring(value) .. " \194\183 " .. noctalia.tr("chatmix.game") end
if value == 0 then return "0" end
return tostring(value)
end
return M