From 1a3fc8d5a4b441ad762ee1a4f62c6eedd5146167 Mon Sep 17 00:00:00 2001 From: iberianpig Date: Wed, 11 Sep 2024 09:13:18 +0900 Subject: [PATCH 1/3] feat: add input handling for pointing stick devices using HIDRAW - Pointing stick devices (only HHKB Studio for now) can now be used as input devices - Modified thumbsense_buffer.rb to handle pointing stick events - Added new files for HID raw device handling, including Bluetooth and USB parsers for HHKB - Introduced PointingStickInput for integrating with pointing stick devices - Added YAML configuration for pointing stick inputs - Added specs for device and parser functionalities --- .../plugin/buffers/thumbsense_buffer.rb | 25 +-- lib/fusuma/plugin/inputs/hidraw/device.rb | 102 +++++++++++ .../inputs/hidraw/hhkb_bluetooth_parser.rb | 162 ++++++++++++++++++ .../plugin/inputs/hidraw/hhkb_usb_parser.rb | 113 ++++++++++++ .../plugin/inputs/pointing_stick_input.rb | 101 +++++++++++ .../plugin/inputs/pointing_stick_input.yml | 4 + .../plugin/inputs/hhkb_studio_bluetooth.txt | 116 +++++++++++++ .../plugin/inputs/hhkb_studio_usb_mouse.txt | 41 +++++ .../plugin/inputs/hidraw/device_spec.rb | 47 +++++ .../hidraw/hhkb_bluetooth_parser_spec.rb | 98 +++++++++++ 10 files changed, 790 insertions(+), 19 deletions(-) create mode 100644 lib/fusuma/plugin/inputs/hidraw/device.rb create mode 100644 lib/fusuma/plugin/inputs/hidraw/hhkb_bluetooth_parser.rb create mode 100644 lib/fusuma/plugin/inputs/hidraw/hhkb_usb_parser.rb create mode 100644 lib/fusuma/plugin/inputs/pointing_stick_input.rb create mode 100644 lib/fusuma/plugin/inputs/pointing_stick_input.yml create mode 100644 spec/fusuma/plugin/inputs/hhkb_studio_bluetooth.txt create mode 100644 spec/fusuma/plugin/inputs/hhkb_studio_usb_mouse.txt create mode 100644 spec/fusuma/plugin/inputs/hidraw/device_spec.rb create mode 100644 spec/fusuma/plugin/inputs/hidraw/hhkb_bluetooth_parser_spec.rb diff --git a/lib/fusuma/plugin/buffers/thumbsense_buffer.rb b/lib/fusuma/plugin/buffers/thumbsense_buffer.rb index 9d731c2..7b9e56f 100644 --- a/lib/fusuma/plugin/buffers/thumbsense_buffer.rb +++ b/lib/fusuma/plugin/buffers/thumbsense_buffer.rb @@ -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 { @@ -26,10 +27,11 @@ 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] @@ -37,21 +39,6 @@ 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 diff --git a/lib/fusuma/plugin/inputs/hidraw/device.rb b/lib/fusuma/plugin/inputs/hidraw/device.rb new file mode 100644 index 0000000..00cdcbb --- /dev/null +++ b/lib/fusuma/plugin/inputs/hidraw/device.rb @@ -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 diff --git a/lib/fusuma/plugin/inputs/hidraw/hhkb_bluetooth_parser.rb b/lib/fusuma/plugin/inputs/hidraw/hhkb_bluetooth_parser.rb new file mode 100644 index 0000000..d0c8df7 --- /dev/null +++ b/lib/fusuma/plugin/inputs/hidraw/hhkb_bluetooth_parser.rb @@ -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 diff --git a/lib/fusuma/plugin/inputs/hidraw/hhkb_usb_parser.rb b/lib/fusuma/plugin/inputs/hidraw/hhkb_usb_parser.rb new file mode 100644 index 0000000..43d379e --- /dev/null +++ b/lib/fusuma/plugin/inputs/hidraw/hhkb_usb_parser.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Fusuma + module Plugin + module Inputs + class Hidraw + class HhkbUsbParser + 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 = 5 # 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 + # instance.parse_hid_report(report_bytes) + case mouse_state + when :begin, :update + :update + else + :begin + 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) + return :end if report_bytes.nil? + + # buttons, x, y, wheel, ac_pan = report_bytes.unpack("Ccccc") # Retrieve 5-byte report + # - `C`: 1 byte unsigned integer (button state) (0..255) + # - `c`: 1 byte signed integer (X-axis) (-127..127) + # - `c`: 1 byte signed integer (Y-axis) (-127..127) + # - `c`: 1 byte signed integer (Wheel) (-127..127) + # - `c`: 1 byte signed integer (AC pan) (-127..127) + # button_states = buttons.to_s(2).rjust(8, "0").chars.map(&:to_i) + + # puts "Raw bytes: #{report_bytes.inspect}" # Display raw byte sequence + # puts "# 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)}" + + :begin + 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_USB + Fusuma::Plugin::Inputs::Hidraw::HhkbUsbParser.new(device).parse do |state| + puts "Touch state: #{state}" + end + else + puts "Bustype is not USB" + end +end diff --git a/lib/fusuma/plugin/inputs/pointing_stick_input.rb b/lib/fusuma/plugin/inputs/pointing_stick_input.rb new file mode 100644 index 0000000..464148f --- /dev/null +++ b/lib/fusuma/plugin/inputs/pointing_stick_input.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "fusuma/device" + +require_relative "hidraw/device" +require_relative "hidraw/hhkb_bluetooth_parser" +require_relative "hidraw/hhkb_usb_parser" + +module Fusuma + module Plugin + module Inputs + # Read pointing stick events + class PointingStickInput < Input + def config_param_types + { + device_name_pattern: String + } + end + + def initialize + super + @device_name_pattern = config_params(:device_name_pattern) + end + + def io + @io ||= begin + reader, writer = IO.pipe + Thread.new do + process_device_events(writer) + writer.close + end + + reader + end + end + + def process_device_events(writer) + hidraw_device = find_hidraw_device(@device_name_pattern, wait: 3) + hidraw_parser = select_hidraw_parser(hidraw_device.bustype) + + mouse_state = nil + + hidraw_parser.new(hidraw_device).parse do |new_state| + next if mouse_state == new_state + + mouse_state = new_state + writer.puts(mouse_state) + end + rescue Errno::EIO => e + MultiLogger.error "#{self.class.name}: #{e}" + retry + end + + # Override Input#read_from_io + def read_from_io + status = io.readline(chomp: true) + Events::Records::GestureRecord.new(gesture: "touch", status: status, finger: 1, delta: nil) + rescue EOFError => e + MultiLogger.error "#{self.class.name}: #{e}" + MultiLogger.error "Shutdown fusuma process..." + Process.kill("TERM", Process.pid) + end + + private + + # Retry and wait until hidraw is found + def find_hidraw_device(device_name_pattern, wait:) + device_finder = Hidraw::DeviceFinder.new + logged = false + loop do + device = device_finder.find(device_name_pattern) + if device + MultiLogger.info "Found pointing stick device: #{device_name_pattern}" + + return device + end + + MultiLogger.warn "No pointing stick device found: #{device_name_pattern}" unless logged + logged = true + + sleep wait + end + end + + # Select parser based on the bus type + # @param bustype [Integer] + def select_hidraw_parser(bustype) + case bustype + when Hidraw::Device::BUS_BLUETOOTH + Hidraw::HhkbBluetoothParser + when Hidraw::Device::BUS_USB + Hidraw::HhkbUsbParser + else + MultiLogger.error "Unsupported bus type: #{bustype}" + exit 1 + end + end + end + end + end +end diff --git a/lib/fusuma/plugin/inputs/pointing_stick_input.yml b/lib/fusuma/plugin/inputs/pointing_stick_input.yml new file mode 100644 index 0000000..72b7c36 --- /dev/null +++ b/lib/fusuma/plugin/inputs/pointing_stick_input.yml @@ -0,0 +1,4 @@ +plugin: + inputs: + pointing_stick_input: + device_name_pattern: diff --git a/spec/fusuma/plugin/inputs/hhkb_studio_bluetooth.txt b/spec/fusuma/plugin/inputs/hhkb_studio_bluetooth.txt new file mode 100644 index 0000000..d4f6c67 --- /dev/null +++ b/spec/fusuma/plugin/inputs/hhkb_studio_bluetooth.txt @@ -0,0 +1,116 @@ +# HHKB-Studio1 +# 0x05, 0x01, // Usage Page (Generic Desktop) 0 +# 0x09, 0x06, // Usage (Keyboard) 2 +# 0xa1, 0x01, // Collection (Application) 4 +# 0x85, 0x7f, // Report ID (127) 6 +# 0x05, 0x07, // Usage Page (Keyboard) 8 +# 0x19, 0xe0, // Usage Minimum (224) 10 +# 0x29, 0xe7, // Usage Maximum (231) 12 +# 0x15, 0x00, // Logical Minimum (0) 14 +# 0x25, 0x01, // Logical Maximum (1) 16 +# 0x95, 0x08, // Report Count (8) 18 +# 0x75, 0x01, // Report Size (1) 20 +# 0x81, 0x02, // Input (Data,Var,Abs) 22 +# 0x95, 0x01, // Report Count (1) 24 +# 0x75, 0x08, // Report Size (8) 26 +# 0x81, 0x01, // Input (Cnst,Arr,Abs) 28 +# 0x05, 0x07, // Usage Page (Keyboard) 30 +# 0x19, 0x00, // Usage Minimum (0) 32 +# 0x29, 0xff, // Usage Maximum (255) 34 +# 0x15, 0x00, // Logical Minimum (0) 36 +# 0x26, 0xff, 0x00, // Logical Maximum (255) 38 +# 0x95, 0x06, // Report Count (6) 41 +# 0x75, 0x08, // Report Size (8) 43 +# 0x81, 0x00, // Input (Data,Arr,Abs) 45 +# 0x05, 0x08, // Usage Page (LEDs) 47 +# 0x19, 0x01, // Usage Minimum (1) 49 +# 0x29, 0x05, // Usage Maximum (5) 51 +# 0x95, 0x05, // Report Count (5) 53 +# 0x75, 0x01, // Report Size (1) 55 +# 0x91, 0x02, // Output (Data,Var,Abs) 57 +# 0x95, 0x01, // Report Count (1) 59 +# 0x75, 0x03, // Report Size (3) 61 +# 0x91, 0x01, // Output (Cnst,Arr,Abs) 63 +# 0xc0, // End Collection 65 +# 0x05, 0x01, // Usage Page (Generic Desktop) 66 +# 0x09, 0x02, // Usage (Mouse) 68 +# 0xa1, 0x01, // Collection (Application) 70 +# 0x85, 0x01, // Report ID (1) 72 +# 0x09, 0x01, // Usage (Pointer) 74 +# 0xa1, 0x00, // Collection (Physical) 76 +# 0x05, 0x09, // Usage Page (Button) 78 +# 0x19, 0x01, // Usage Minimum (1) 80 +# 0x29, 0x08, // Usage Maximum (8) 82 +# 0x15, 0x00, // Logical Minimum (0) 84 +# 0x25, 0x01, // Logical Maximum (1) 86 +# 0x95, 0x08, // Report Count (8) 88 +# 0x75, 0x01, // Report Size (1) 90 +# 0x81, 0x02, // Input (Data,Var,Abs) 92 +# 0x05, 0x01, // Usage Page (Generic Desktop) 94 +# 0x09, 0x30, // Usage (X) 96 +# 0x09, 0x31, // Usage (Y) 98 +# 0x15, 0x81, // Logical Minimum (-127) 100 +# 0x25, 0x7f, // Logical Maximum (127) 102 +# 0x95, 0x02, // Report Count (2) 104 +# 0x75, 0x08, // Report Size (8) 106 +# 0x81, 0x06, // Input (Data,Var,Rel) 108 +# 0x09, 0x38, // Usage (Wheel) 110 +# 0x15, 0x81, // Logical Minimum (-127) 112 +# 0x25, 0x7f, // Logical Maximum (127) 114 +# 0x95, 0x01, // Report Count (1) 116 +# 0x75, 0x08, // Report Size (8) 118 +# 0x81, 0x06, // Input (Data,Var,Rel) 120 +# 0x05, 0x0c, // Usage Page (Consumer Devices) 122 +# 0x0a, 0x38, 0x02, // Usage (AC Pan) 124 +# 0x15, 0x81, // Logical Minimum (-127) 127 +# 0x25, 0x7f, // Logical Maximum (127) 129 +# 0x95, 0x01, // Report Count (1) 131 +# 0x75, 0x08, // Report Size (8) 133 +# 0x81, 0x06, // Input (Data,Var,Rel) 135 +# 0xc0, // End Collection 137 +# 0xc0, // End Collection 138 +# 0x05, 0x01, // Usage Page (Generic Desktop) 139 +# 0x09, 0x80, // Usage (System Control) 141 +# 0xa1, 0x01, // Collection (Application) 143 +# 0x85, 0x02, // Report ID (2) 145 +# 0x19, 0x01, // Usage Minimum (1) 147 +# 0x2a, 0xb7, 0x00, // Usage Maximum (183) 149 +# 0x15, 0x01, // Logical Minimum (1) 152 +# 0x26, 0xb7, 0x00, // Logical Maximum (183) 154 +# 0x95, 0x01, // Report Count (1) 157 +# 0x75, 0x10, // Report Size (16) 159 +# 0x81, 0x00, // Input (Data,Arr,Abs) 161 +# 0xc0, // End Collection 163 +# 0x05, 0x0c, // Usage Page (Consumer Devices) 164 +# 0x09, 0x01, // Usage (Consumer Control) 166 +# 0xa1, 0x01, // Collection (Application) 168 +# 0x85, 0x03, // Report ID (3) 170 +# 0x15, 0x01, // Logical Minimum (1) 172 +# 0x26, 0x9c, 0x02, // Logical Maximum (668) 174 +# 0x19, 0x01, // Usage Minimum (1) 177 +# 0x2a, 0x9c, 0x02, // Usage Maximum (668) 179 +# 0x75, 0x10, // Report Size (16) 182 +# 0x95, 0x01, // Report Count (1) 184 +# 0x81, 0x00, // Input (Data,Arr,Abs) 186 +# 0xc0, // End Collection 188 +# 0x06, 0x60, 0xff, // Usage Page (Vendor Usage Page 0xff60) 189 +# 0x09, 0x61, // Usage (Vendor Usage 0x61) 192 +# 0xa1, 0x01, // Collection (Application) 194 +# 0x85, 0x04, // Report ID (4) 196 +# 0x09, 0x75, // Usage (Vendor Usage 0x75) 198 +# 0x15, 0x00, // Logical Minimum (0) 200 +# 0x26, 0xff, 0x00, // Logical Maximum (255) 202 +# 0x95, 0x20, // Report Count (32) 205 +# 0x75, 0x08, // Report Size (8) 207 +# 0x81, 0x02, // Input (Data,Var,Abs) 209 +# 0x09, 0x76, // Usage (Vendor Usage 0x76) 211 +# 0x15, 0x00, // Logical Minimum (0) 213 +# 0x26, 0xff, 0x00, // Logical Maximum (255) 215 +# 0x95, 0x20, // Report Count (32) 218 +# 0x75, 0x08, // Report Size (8) 220 +# 0x91, 0x02, // Output (Data,Var,Abs) 222 +# 0xc0, // End Collection 224 +# +R: 225 05 01 09 06 a1 01 85 7f 05 07 19 e0 29 e7 15 00 25 01 95 08 75 01 81 02 95 01 75 08 81 01 05 07 19 00 29 ff 15 00 26 ff 00 95 06 75 08 81 00 05 08 19 01 29 05 95 05 75 01 91 02 95 01 75 03 91 01 c0 05 01 09 02 a1 01 85 01 09 01 a1 00 05 09 19 01 29 08 15 00 25 01 95 08 75 01 81 02 05 01 09 30 09 31 15 81 25 7f 95 02 75 08 81 06 09 38 15 81 25 7f 95 01 75 08 81 06 05 0c 0a 38 02 15 81 25 7f 95 01 75 08 81 06 c0 c0 05 01 09 80 a1 01 85 02 19 01 2a b7 00 15 01 26 b7 00 95 01 75 10 81 00 c0 05 0c 09 01 a1 01 85 03 15 01 26 9c 02 19 01 2a 9c 02 75 10 95 01 81 00 c0 06 60 ff 09 61 a1 01 85 04 09 75 15 00 26 ff 00 95 20 75 08 81 02 09 76 15 00 26 ff 00 95 20 75 08 91 02 c0 +N: HHKB-Studio1 +I: 5 04fe 0016 diff --git a/spec/fusuma/plugin/inputs/hhkb_studio_usb_mouse.txt b/spec/fusuma/plugin/inputs/hhkb_studio_usb_mouse.txt new file mode 100644 index 0000000..04308e6 --- /dev/null +++ b/spec/fusuma/plugin/inputs/hhkb_studio_usb_mouse.txt @@ -0,0 +1,41 @@ +# PFU Limited HHKB-Studio +# 0x05, 0x01, // Usage Page (Generic Desktop) 0 +# 0x09, 0x02, // Usage (Mouse) 2 +# 0xa1, 0x01, // Collection (Application) 4 +# 0x09, 0x01, // Usage (Pointer) 6 +# 0xa1, 0x00, // Collection (Physical) 8 +# 0x05, 0x09, // Usage Page (Button) 10 +# 0x19, 0x01, // Usage Minimum (1) 12 +# 0x29, 0x08, // Usage Maximum (8) 14 +# 0x15, 0x00, // Logical Minimum (0) 16 +# 0x25, 0x01, // Logical Maximum (1) 18 +# 0x95, 0x08, // Report Count (8) 20 +# 0x75, 0x01, // Report Size (1) 22 +# 0x81, 0x02, // Input (Data,Var,Abs) 24 +# 0x05, 0x01, // Usage Page (Generic Desktop) 26 +# 0x09, 0x30, // Usage (X) 28 +# 0x09, 0x31, // Usage (Y) 30 +# 0x15, 0x81, // Logical Minimum (-127) 32 +# 0x25, 0x7f, // Logical Maximum (127) 34 +# 0x95, 0x02, // Report Count (2) 36 +# 0x75, 0x08, // Report Size (8) 38 +# 0x81, 0x06, // Input (Data,Var,Rel) 40 +# 0x09, 0x38, // Usage (Wheel) 42 +# 0x15, 0x81, // Logical Minimum (-127) 44 +# 0x25, 0x7f, // Logical Maximum (127) 46 +# 0x95, 0x01, // Report Count (1) 48 +# 0x75, 0x08, // Report Size (8) 50 +# 0x81, 0x06, // Input (Data,Var,Rel) 52 +# 0x05, 0x0c, // Usage Page (Consumer Devices) 54 +# 0x0a, 0x38, 0x02, // Usage (AC Pan) 56 +# 0x15, 0x81, // Logical Minimum (-127) 59 +# 0x25, 0x7f, // Logical Maximum (127) 61 +# 0x95, 0x01, // Report Count (1) 63 +# 0x75, 0x08, // Report Size (8) 65 +# 0x81, 0x06, // Input (Data,Var,Rel) 67 +# 0xc0, // End Collection 69 +# 0xc0, // End Collection 70 +# +R: 71 05 01 09 02 a1 01 09 01 a1 00 05 09 19 01 29 08 15 00 25 01 95 08 75 01 81 02 05 01 09 30 09 31 15 81 25 7f 95 02 75 08 81 06 09 38 15 81 25 7f 95 01 75 08 81 06 05 0c 0a 38 02 15 81 25 7f 95 01 75 08 81 06 c0 c0 +N: PFU Limited HHKB-Studio +I: 3 04fe 0016 diff --git a/spec/fusuma/plugin/inputs/hidraw/device_spec.rb b/spec/fusuma/plugin/inputs/hidraw/device_spec.rb new file mode 100644 index 0000000..7c27421 --- /dev/null +++ b/spec/fusuma/plugin/inputs/hidraw/device_spec.rb @@ -0,0 +1,47 @@ +require "spec_helper" +require "fusuma/plugin/inputs/hidraw/device" + +module Fusuma::Plugin::Inputs + RSpec.describe Hidraw::Device do + let(:hidraw_path) { "/dev/hidraw0" } + let(:device) { described_class.new(hidraw_path: hidraw_path) } + + before do + allow(File).to receive(:open).with(hidraw_path, "rb+").and_yield(double(ioctl: true)) + end + + describe "#initialize" do + it "sets the hidraw_path" do + expect(device.hidraw_path).to eq(hidraw_path) + end + + it "loads device info" do + device_name = "Test Device" + + # struct hidraw_devinfo { + # __u32 bustype; + # __s16 vendor; + # __s16 product; + # }; + hidraw_definfo = [ + 0x03, # BUS_USB + 0x1234, + 0x5678 + ].pack("LSS") + + allow_any_instance_of(described_class).to receive(:fetch_ioctl_data) + .with(anything, described_class::HIDIOCGRAWNAME) + .and_return(device_name) + + allow_any_instance_of(described_class).to receive(:fetch_ioctl_data) + .with(anything, described_class::HIDIOCGRAWINFO, anything) + .and_return(hidraw_definfo) + + expect(device.name).to eq("Test Device") + expect(device.bustype).to eq(described_class::BUS_USB) + expect(device.vendor_id).to eq("1234") + expect(device.product_id).to eq("5678") + end + end + end +end diff --git a/spec/fusuma/plugin/inputs/hidraw/hhkb_bluetooth_parser_spec.rb b/spec/fusuma/plugin/inputs/hidraw/hhkb_bluetooth_parser_spec.rb new file mode 100644 index 0000000..d1c59c1 --- /dev/null +++ b/spec/fusuma/plugin/inputs/hidraw/hhkb_bluetooth_parser_spec.rb @@ -0,0 +1,98 @@ +require "spec_helper" +require "timeout" +require "tempfile" + +require "fusuma/multi_logger" +require "fusuma/plugin/inputs/hidraw/hhkb_bluetooth_parser" +require "fusuma/plugin/inputs/hidraw/device" + +RSpec.describe Fusuma::Plugin::Inputs::Hidraw::HhkbBluetoothParser do + let(:parser) { described_class.new(hidraw_device) } + let(:hidraw_device) { double("Hidraw::Device", hidraw_path: Tempfile.new("hidraw_device")) } + let(:report_content) { "" } + let(:valid_mouse_report) { "\x01\x00\x00\x00\x00\x00\x01\x00\x00" } # Mock mouse report + let(:valid_keyboard_report) { "\x7F\x00\x00\x00\x04\x00\x00\x00\x00" } # Mock keyboard report + let(:unknown_report) { "\xFF" } # Unknown report ID + + describe "#parse" do + before do + # Set up the test file + hidraw_device.hidraw_path.write(report_content) + hidraw_device.hidraw_path.close + end + + after do + hidraw_device.hidraw_path.unlink + end + + context "when a valid mouse report is given" do + let(:report_content) { valid_mouse_report } + + it "should yield :begin mouse_state for a valid mouse report" do + expect { |b| parser.parse(&b) }.to yield_with_args(:begin) + end + end + + context "when a valid keyboard report is given" do + context "without a previous mouse state" do + let(:report_content) { + [ + valid_keyboard_report # Mouse state nil <- should keep previous state + ].join + } + + it "should yield :begin mouse_state for a valid keyboard report" do + expect { |b| parser.parse(&b) }.to yield_with_args(nil) + end + end + + context "when previous mouse state is :update" do + let(:report_content) { + [ + valid_mouse_report, # Mouse state :begin + valid_mouse_report, # Mouse state :update + valid_keyboard_report # Mouse state :update <- should keep previous state + ].join + } + + it "should keep the previous mouse_state" do + expect { |b| parser.parse(&b) }.to yield_successive_args(:begin, :update, :update) + end + end + end + + it "should yield :end mouse_state when timeout occurs" do + # read_with_timeout + # "" : treated as timeout when returning an empty string + # nil: exits the while loop when returning nil + allow(parser).to receive(:read_with_timeout).with(any_args).and_return("", nil) + expect { |b| parser.parse(&b) }.to yield_with_args(:end) + end + end + + describe "#parse_hid_report" do + it "parses a valid mouse report" do + expect(parser.parse_hid_report(valid_mouse_report)).to be :mouse + end + + it "parses a valid keyboard report" do + expect(parser.parse_hid_report(valid_keyboard_report)).to be :keyboard + end + + it "handles unknown report IDs" do + expect(Fusuma::MultiLogger).to receive(:warn).with(/Unknown Report ID: 255/) + expect(parser.parse_hid_report(unknown_report)).to be nil + end + end + + describe "#translate_keycode" do + it "translates valid keycodes" do + expect(parser.translate_keycode(4)).to eq("a and A") + expect(parser.translate_keycode(100)).to eq("0x64") # Hexadecimal + end + + it "returns unknown code for unrecognized keycodes" do + expect(parser.translate_keycode(200)).to eq("0xc8") # Hexadecimal + end + end +end From a58ed8ba94114e0b81bd7e3be061c3fa1708cf61 Mon Sep 17 00:00:00 2001 From: iberianpig Date: Fri, 13 Sep 2024 23:29:14 +0900 Subject: [PATCH 2/3] fix: don't detect when only SHIFT key is pressed - Do not detect thumbsense when a modifier key is pressed after touch is released - This prevents the extension of timeout when operating pointing stick - For example, when you press SHIFT while operating pointing stick, release pointing stick, and then press F, it is converted to a click event --- .../plugin/detectors/thumbsense_detector.rb | 2 ++ .../detectors/thumbsense_detector_spec.rb | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/lib/fusuma/plugin/detectors/thumbsense_detector.rb b/lib/fusuma/plugin/detectors/thumbsense_detector.rb index cc35848..caa7f14 100644 --- a/lib/fusuma/plugin/detectors/thumbsense_detector.rb +++ b/lib/fusuma/plugin/detectors/thumbsense_detector.rb @@ -126,6 +126,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 diff --git a/spec/fusuma/plugin/detectors/thumbsense_detector_spec.rb b/spec/fusuma/plugin/detectors/thumbsense_detector_spec.rb index f4577e8..4201cf0 100644 --- a/spec/fusuma/plugin/detectors/thumbsense_detector_spec.rb +++ b/spec/fusuma/plugin/detectors/thumbsense_detector_spec.rb @@ -84,6 +84,23 @@ def keypress_generator(code:, status:, layer: nil, time: Time.now) expect(index_event.record).to be_a Events::Records::IndexRecord expect(index_event.record.index).to eq Config::Index.new([:remap, "LEFTSHIFT"]) end + + context "when touch is released" do + before do + [ + thumbsense_generator(finger: 1, status: "end") + ].each { |event| @thumbsense_buffer.buffer(event) } + end + + it "does NOT detect thumbsense" do + expect(Fusuma::Plugin::Remap::LayerManager.instance).to receive(:send_layer).with(layer: ThumbsenseDetector::LAYER_CONTEXT, remove: true) + + context_event, index_event = @detector.detect(@buffers) + + expect(context_event).to be_nil + expect(index_event).to be_nil + end + end end context "when non-Modifier key is pressed" do From 003ce9e2fd737c968f85d82503630d174ea3ac2d Mon Sep 17 00:00:00 2001 From: iberianpig Date: Fri, 13 Sep 2024 23:35:20 +0900 Subject: [PATCH 3/3] refactor: remove unused method --- lib/fusuma/plugin/detectors/thumbsense_detector.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/fusuma/plugin/detectors/thumbsense_detector.rb b/lib/fusuma/plugin/detectors/thumbsense_detector.rb index caa7f14..88f3e4d 100644 --- a/lib/fusuma/plugin/detectors/thumbsense_detector.rb +++ b/lib/fusuma/plugin/detectors/thumbsense_detector.rb @@ -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?