Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Pointing Stick Input for HHKB Studio #4

Merged
merged 3 commits into from
Sep 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 6 additions & 19 deletions lib/fusuma/plugin/buffers/thumbsense_buffer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module Buffers
# manage events and generate command
class ThumbsenseBuffer < Buffer
DEFAULT_SOURCE = "remap_touchpad_input"
POINTING_STICK_SOURCE = "pointing_stick_input"

def config_param_types
{
Expand All @@ -26,32 +27,18 @@ def clear_expired(*)
# @param event [Event]
# @return [NilClass, ThumbsenseBuffer]
def buffer(event)
return if event&.tag != source

@events.push(event)
self
case event.tag
when source, POINTING_STICK_SOURCE
@events.push(event)
self
end
end

# return [Integer]
def finger
@events.map { |e| e.record.finger }.max
end

def empty?
@events.empty?
end

def present?
!empty?
end

def select_by_events(&block)
return enum_for(:select) unless block

events = @events.select(&block)
self.class.new events
end

def ended?(event)
event.record.status == "end"
end
Expand Down
7 changes: 2 additions & 5 deletions lib/fusuma/plugin/detectors/thumbsense_detector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,6 @@ def pressed_codes
codes
end

# @return [TrueClass, FalseClass]
def touching?
!touch_released?(@thumbsense_buffer)
end

# @return [TrueClass, FalseClass]
def touch_released?
return true if @thumbsense_buffer.empty?
Expand All @@ -126,6 +121,8 @@ def thumbsense_layer?
last_keypress = @keypress_buffer.events.last.record
return if last_keypress.status == "released"

return if MODIFIER_KEYS.include?(last_keypress.code)

current_layer = last_keypress&.layer
current_layer && current_layer["thumbsense"]
end
Expand Down
102 changes: 102 additions & 0 deletions lib/fusuma/plugin/inputs/hidraw/device.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

require "fusuma/device"
require "fusuma/multi_logger"

module Fusuma
module Plugin
module Inputs
# Read pointing stick events
class Hidraw
class Device
# Definitions of IOCTL commands
HIDIOCGRAWNAME = 0x80804804
HIDIOCGRAWPHYS = 0x80404805
HIDIOCGRAWINFO = 0x80084803
HIDIOCGRDESCSIZE = 0x80044801
HIDIOCGRDESC = 0x90044802

# Definitions of bus types
BUS_PCI = 0x01
BUS_ISAPNP = 0x02
BUS_USB = 0x03
BUS_HIL = 0x04
BUS_BLUETOOTH = 0x05
BUS_VIRTUAL = 0x06

attr_reader :hidraw_path, :name, :bustype, :vendor_id, :product_id

def initialize(hidraw_path:)
@hidraw_path = hidraw_path
load_device_info
end

private

def load_device_info
File.open(@hidraw_path, "rb+") do |file|
@name = fetch_ioctl_data(file, HIDIOCGRAWNAME).strip

info = fetch_ioctl_data(file, HIDIOCGRAWINFO, [0, 0, 0].pack("LSS"))
@bustype, vendor, product = info.unpack("LSS")
@vendor_id = vendor.to_s(16)
@product_id = product.to_s(16)
end
rescue => e
MultiLogger.error "Error loading device info: #{e.message}"
end

def fetch_ioctl_data(file, ioctl_command, buffer = " " * 256)
file.ioctl(ioctl_command, buffer)
buffer
rescue Errno::EIO
MultiLogger.warn "Failed to retrieve data with IOCTL command #{ioctl_command}, the device might not support this operation."
end
end

class DeviceFinder
def find(device_name_pattern)
device_name_pattern = Regexp.new(device_name_pattern) if device_name_pattern.is_a?(String)
event_path = find_pointer_device_path(device_name_pattern)
return nil unless event_path

hidraw_path = find_hidraw_path(event_path)

return Device.new(hidraw_path: hidraw_path) if hidraw_path

nil
end

private

def find_hidraw_path(event_path)
event_abs_path = File.realpath(event_path)
parent_path = event_abs_path.gsub(%r{/input/input\d+/.*}, "")
locate_hidraw_device(parent_path)
end

def find_pointer_device_path(device_name_pattern)
Fusuma::Device.reset

device = Fusuma::Device.all.find do |device|
device.name =~ device_name_pattern && device.capabilities == "pointer"
end

device&.then { |d| "/sys/class/input/#{d.id}" }
end

def locate_hidraw_device(parent_path)
Dir.glob("#{parent_path}/hidraw/hidraw*").find do |path|
if File.exist?(path)
hidraw_device_path = path.gsub(%r{^/.*hidraw/hidraw}, "/dev/hidraw")
return hidraw_device_path if File.readable?(hidraw_device_path)
end
end

nil
end
end
end
end
end
end
162 changes: 162 additions & 0 deletions lib/fusuma/plugin/inputs/hidraw/hhkb_bluetooth_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# frozen_string_literal: true

module Fusuma
module Plugin
module Inputs
class Hidraw
class HhkbBluetoothParser
BASE_TIMEOUT = 0.03 # Base timeout value for reading reports
MAX_TIMEOUT = 0.2 # Maximum timeout value before failure
MULTIPLIER = 1.1 # Multiplier to exponentially increase timeout

MAX_REPORT_SIZE = 9 # Maximum report size in bytes

# @param hidraw_device [Hidraw::Device] the HID raw device
def initialize(hidraw_device)
@hidraw_device = hidraw_device
end

# Parse HID raw device events.
def parse
File.open(@hidraw_device.hidraw_path, "rb") do |device|
timeout = nil

# Continuously read reports from the device.
while (report = read_with_timeout(device, timeout))
mouse_state = if report.empty?
# Handle timeout case
:end
else
case parse_hid_report(report)
when :mouse
case mouse_state
when :begin, :update
:update
else
:begin
end
when :keyboard
# Continue mouse_state when keyboard operation
mouse_state
else
:end
end
end

case mouse_state
when :begin, :update
timeout = update_timeout(timeout)
when :end
timeout = nil
end

yield mouse_state
end
end
end

# Reads the HID report from the device with a timeout.
# @param device [File] the opened device file
# @param timeout [Float] the timeout duration
# @return [String] the HID report as bytes or an empty string on timeout
def read_with_timeout(device, timeout)
# puts "Timeout: #{timeout}" # Log timeout for debugging
Timeout.timeout(timeout) { device.read(MAX_REPORT_SIZE) }
rescue Timeout::Error
""
end

# Update the timeout based on previous value.
# @param timeout [Float, nil] previously set timeout
# @return [Float] the updated timeout value
def update_timeout(timeout)
return BASE_TIMEOUT if timeout.nil?

[timeout * MULTIPLIER, MAX_TIMEOUT].min
end

# Parse the HID report to determine its type.
# @param report_bytes [String] the HID report as byte data
# @return [Symbol, nil] symbol indicating type of report or nil on error
def parse_hid_report(report_bytes)
report_id = report_bytes.getbyte(0)
case report_id
when 1
# parse_mouse_report(report_bytes)
:mouse
when 127
# parse_keyboard_report(report_bytes)
:keyboard
else
MultiLogger.warn "Unknown Report ID: #{report_id}"
nil
end
end

# Parse mouse report data.
# @param report_bytes [String] the HID mouse report as byte data
def parse_mouse_report(report_bytes)
puts "Raw bytes: #{report_bytes.inspect}" # Display raw byte bytes

report_id, buttons, x, y, wheel, ac_pan = report_bytes.unpack("CCcccc") # Retrieve 6-byte report
# - `C`: 1 byte unsigned integer (report ID) (0..255)
# - `C`: 1 byte unsigned integer (button state) (0..255)
# - `c`: 1 byte signed integer (x-axis) (-128..127)
# - `c`: 1 byte signed integer (y-axis) (-128..127)
# - `c`: 1 byte signed integer (wheel) (-128..127)
# - `c`: 1 byte signed integer (AC pan) (-128..127)
button_states = buttons.to_s(2).rjust(8, "0").chars.map(&:to_i)

puts "# ReportID: #{report_id} / Button: #{button_states.join(" ")} | X: #{x.to_s.rjust(4)} | Y: #{y.to_s.rjust(4)} | Wheel: #{wheel.to_s.rjust(4)} | AC Pan: #{ac_pan.to_s.rjust(4)}"
end

# Parse keyboard report data.
# @param report_bytes [String] the HID keyboard report as byte data
def parse_keyboard_report(report_bytes)
report_id, modifiers, _reserved1, *keys = report_bytes.unpack("CCCC6") # Retrieve 9-byte report
# - `C`: 1 byte unsigned integer (report ID) (0..255)
# - `C`: 1 byte unsigned integer (modifier keys) (0..255)
# - `C`: 1 byte reserved (0)
# - `C`: 6 bytes of keycodes (0..255)
modifier_states = %w[LeftControl LeftShift LeftAlt LeftGUI RightControl RightShift RightAlt RightGUI].map.with_index { |m, i| "#{m}: #{((modifiers & (1 << i)) != 0) ? 1 : 0}" }
keys_output = keys.map { |key| (key == 0) ? "0x70000" : translate_keycode(key) }
puts "# ReportID: #{report_id} / #{modifier_states.join(" | ")} | Keyboard #{keys_output}"
end

# Translate keycode to its string representation.
# @param keycode [Integer] the keycode to translate
# @return [String] the string representation of the keycode
def translate_keycode(keycode)
# Map of keycodes to their respective characters
keycodes = {
4 => "a and A", 7 => "d and D", 16 => "s and S", 19 => "w and W",
9 => "f and F", 10 => "g and G", 14 => "j and J", 15 => "k and K",
33 => "[ and {", 47 => "] and }"
# Add more as needed
}
keycodes[keycode] || "0x#{keycode.to_s(16)}" # Return hexadecimal if not found
end
end
end
end
end
end

if $PROGRAM_NAME == __FILE__
require "timeout"

require_relative "device"
require "fusuma/plugin/inputs/libinput_command_input"

device = Fusuma::Plugin::Inputs::Hidraw::DeviceFinder.new.find("HHKB-Studio")
return if device.nil?

puts "Device: #{device.name} (#{device.vendor_id}:#{device.product_id})"
if device.bustype == Fusuma::Plugin::Inputs::Hidraw::Device::BUS_BLUETOOTH
Fusuma::Plugin::Inputs::Hidraw::HhkbBluetoothParser.new(device).parse do |state|
puts "Touch state: #{state}"
end
else
puts "Bustype is not BUS_BLUETOOTH"
end
end
Loading
Loading