KMBox

@rebind / kmbox-netv0.2.4Public

Let programs written for KMBox Net, Ferrum, or DHZBox send keyboard and mouse input through Rebind Link over your network.

Files

Older release. Installs get 0.2.4.

text.luau9.5 KB

--[[ Ferrum and DHZBox text commands

  Both arrive as ASCII function calls on the KMBox Net port.

  Ferrum: the KM API at docs.xferrum.dev, which Ferrum documents over serial.
  Lines start with km., device. or m(, may hold several commands split by ;,
  and are answered in its echo format: the line, the result if any, then the
  ">>> " prompt. km.echo(0) leaves only results.

  DHZBox: one call per datagram, such as move(3,-2), with letters Caesar
  shifted by a device setting and digits left alone. The shift is found by
  matching known command names. Every datagram is answered, because clients
  block on a reply. Taken from third-party clients; the vendor spec is not
  public, so key names (the Ferrum table is reused), the echoed reply, the
  monitor report and the wheel scaling and sign are unverified.
]]

local BUTTONS = { left = 1, right = 2, middle = 3, side1 = 4, side2 = 5 }

-- Ferrum key strings (HID keyboard usages); digits are always key numbers
local KEYS = {
  enter = 40, esc = 41, back = 42, tab = 43, space = 44, ["-"] = 45,
  ["."] = 55, caps = 57, print = 70, scroll = 71, pause = 72, insert = 73,
  home = 74, pgup = 75, del = 76, ["end"] = 77, pgdown = 78, right = 79,
  left = 80, down = 81, up = 82, aplcat = 101,
  ["ctr-l"] = 224, ["shift-l"] = 225, ["alt-l"] = 226, ["gui-l"] = 227,
  ["ctr-r"] = 228, ["shift-r"] = 229, ["alt-r"] = 230, ["gui-r"] = 231,
}
for i = 0, 25 do
  KEYS[string.char(97 + i)] = 4 + i
end
for i = 1, 12 do
  KEYS[`f{i}`] = 57 + i
end
for i = 13, 20 do
  KEYS[`f{i}`] = 91 + i
end

local DHZ_NAMES = {}
for _, name in
  {
    "move", "left", "right", "middle", "side1", "side2", "wheel", "mouse",
    "keydown", "keyup", "isdown_left", "isdown_right", "isdown_middle",
    "isdown_side1", "isdown_side2", "monitor", "mask_left", "mask_right",
    "mask_middle", "mask_wheel", "mask_side1", "mask_side2", "mask_x",
    "mask_y", "mask_all", "mask_keyboard", "dismask_keyboard",
    "dismask_keyboard_all",
  }
do
  DHZ_NAMES[name] = true
end

local function usage_of(arg)
  if arg == nil then
    return nil
  end
  if arg:match("^%d+$") then
    return tonumber(arg)
  end
  return KEYS[arg:lower()]
end

local function key_is_down(usage)
  for _, name in Input.GetActiveKeys() do
    if HID.Usage(name) == usage then
      return true
    end
  end
  return false
end

local function bit(down)
  return if down then "1" else "0"
end

local function balanced(s)
  local depth = 0
  for c in s:gmatch("[%(%)%[%]]") do
    depth += if c == "(" or c == "[" then 1 else -1
    if depth < 0 then
      return false
    end
  end
  return depth == 0
end

local function split_args(s)
  local args = {}
  for a in s:gmatch("[^,]+") do
    table.insert(args, a)
  end
  return args
end

-- DHZBox wheel. Some clients send 120 per notch. Positive scrolls down, as
-- on KMBox Net; the DHZBox sign is unverified.
local function wheel(w)
  if math.abs(w) >= 120 then
    w = math.sign(w) * (math.abs(w) // 120)
  end
  if w ~= 0 then
    HID.Scroll(-w)
  end
end

-- api: button(n, down), key(usage, down), send(ip, port, bytes)
local function new(api)
  local echo = true
  local shift = nil -- cached DHZBox letter shift
  local monitor = nil -- { ip, port } for DHZBox button reports
  local last_report = nil

  local function tap(down, up)
    down()
    Run(function()
      Sleep(Math.Random(75, 125))
      up()
    end)
  end

  local function tap_key(usage)
    if usage then
      tap(function()
        api.key(usage, true)
      end, function()
        api.key(usage, false)
      end)
    end
  end

  -- one Ferrum command; returns its result text, or nil for none
  local function ferrum(name, args)
    local button = BUTTONS[name:match("^km%.(%w+)$") or ""]
    if button then
      if args[1] == nil then
        return bit(Input.IsDown(`Mouse{button}`))
      end
      api.button(button, tonumber(args[1]) == 1)
    elseif name == "km.move" or name == "m" then
      local x, y = tonumber(args[1]), tonumber(args[2])
      if x and y and (x ~= 0 or y ~= 0) then
        HID.Move(x, y)
      end
    elseif name == "km.click" then
      local n = tonumber(args[1])
      if n and n >= 0 and n <= 4 then
        tap(function()
          api.button(n + 1, true)
        end, function()
          api.button(n + 1, false)
        end)
      end
    elseif name == "km.wheel" then
      local n = tonumber(args[1])
      if n and n ~= 0 then
        HID.Scroll(math.clamp(n, -128, 127)) -- positive scrolls up
      end
    elseif name == "km.down" or name == "km.up" or name == "km.multidown" or name == "km.multiup" then
      local down = name:sub(-4) == "down"
      for _, a in args do
        local usage = usage_of(a)
        if usage then
          api.key(usage, down)
        end
      end
    elseif name == "km.press" or name == "km.multipress" then
      for _, a in args do
        tap_key(usage_of(a))
      end
    elseif name == "km.isdown" then
      local usage = usage_of(args[1])
      return bit(usage ~= nil and key_is_down(usage))
    elseif name == "km.version" then
      return "kmbox: Ferrum"
    elseif name == "km.echo" then
      if args[1] == nil then
        return bit(echo)
      end
      echo = tonumber(args[1]) ~= 0
    elseif name == "km.has_mouse" or name == "km.has_keyboard" then
      return "1"
    elseif name == "km.has_controller" then
      return "0"
    elseif name == "device.VID" and args[1] == nil then
      return "VID=046d"
    elseif name == "device.PID" and args[1] == nil then
      return "PID=c53f"
    end
    -- locks, callbacks, catch_xy, controller and the rest are accepted
    -- without effect, as Ferrum does for commands it does not support
    return nil
  end

  -- one Ferrum line; returns the reply text, or nil when the line is invalid
  local function ferrum_line(raw)
    local line = raw:gsub("[^%w%+%-_%.,%(%)%[%];=]", "")
    local calls = {}
    for part in line:gmatch("[^;]+") do
      local name, inner = part:match("^([%w_%.%+%-]+)%((.*)%)$")
      if not name or not balanced(inner) then
        return nil
      end
      table.insert(calls, { name, split_args(inner) })
    end
    if #calls == 0 then
      return nil
    end
    local result = nil
    for _, call in calls do
      result = ferrum(call[1], call[2]) or result
    end
    local out = if result then result .. "\r\n" else ""
    if echo then
      out = raw:match("^%s*(.-)%s*$") .. "\r\n" .. out .. ">>> "
    end
    return out
  end

  local function unshift(s, k)
    return (
      s:gsub("%a", function(c)
        local base = if c:match("%l") then 97 else 65
        return string.char((c:byte() - base - k) % 26 + base)
      end)
    )
  end

  local function dhz_parse(data, k)
    local plain = unshift(data, k)
    local name, inner = plain:match("^%s*([%w_]+)%((.-)%)%s*$")
    if name and DHZ_NAMES[name] then
      return name, split_args(inner)
    end
    return nil
  end

  -- one DHZBox datagram; returns the reply text
  local function dhz(data, ip)
    local name, args = nil, nil
    if shift then
      name, args = dhz_parse(data, shift)
    end
    if not name then
      for k = 0, 25 do
        name, args = dhz_parse(data, k)
        if name then
          shift = k
          break
        end
      end
    end
    if not name then
      return data
    end

    local n = tonumber(args[1])
    local button = BUTTONS[name]
    if button then
      api.button(button, n == 1)
    elseif name == "move" then
      local y = tonumber(args[2])
      if n and y then
        HID.Move(n, y)
      end
    elseif name == "wheel" then
      if n then
        wheel(n)
      end
    elseif name == "mouse" then
      local x, y, w = tonumber(args[2]), tonumber(args[3]), tonumber(args[4])
      if n and x and y and w then
        for b = 1, 5 do
          api.button(b, bit32.btest(n, bit32.lshift(1, b - 1)))
        end
        if x ~= 0 or y ~= 0 then
          HID.Move(x, y)
        end
        wheel(w)
      end
    elseif name == "keydown" or name == "keyup" then
      local usage = usage_of(args[1])
      if usage then
        api.key(usage, name == "keydown")
      end
    elseif name:match("^isdown_") then
      return bit(Input.IsDown(`Mouse{BUTTONS[name:sub(8)]}`))
    elseif name == "monitor" then
      monitor = if n and n > 0 then { ip = ip, port = n } else nil
      last_report = nil
    end
    -- masks are accepted without effect, as for KMBox Net
    return data
  end

  local t = {}

  -- route a text datagram; returns the reply, or nil for no reply
  function t.handle(data, ip)
    if not (data:match("^%s*km%.") or data:match("^%s*device%.") or data:match("^%s*m%(")) then
      return dhz(data, ip)
    end
    local out = {}
    for line in data:gmatch("[^\r\n]+") do
      local reply = ferrum_line(line)
      if reply and reply ~= "" then
        table.insert(out, reply)
      end
    end
    return if #out > 0 then table.concat(out) else nil
  end

  -- DHZBox monitor: L|M|R|S1|S2 whenever a physical button changes
  function t.tick()
    if not monitor then
      return
    end
    local report = table.concat({
      bit(Input.IsDown("Mouse1")),
      bit(Input.IsDown("Mouse3")),
      bit(Input.IsDown("Mouse2")),
      bit(Input.IsDown("Mouse4")),
      bit(Input.IsDown("Mouse5")),
    }, "|")
    if report ~= last_report then
      last_report = report
      api.send(monitor.ip, monitor.port, report)
    end
  end

  function t.reset()
    echo, shift, monitor, last_report = true, nil, nil, nil
  end

  return t
end

return { new = new }

5 files, 27.4 KB.