diff --git a/main.s b/main.s index f9e99f0..9eb6ebf 100644 --- a/main.s +++ b/main.s @@ -160,6 +160,24 @@ tpData2 DS 1 tpData3 DS 1 tpData4 DS 1 +; Middle-button scroll state +midBtnState DS 1 ; 0=idle, 1=pressed-undecided, 2=scrolling +midBtnTimer DS 1 ; countdown timer in ms (SOF ticks) +midBtnSendClick DS 1 ; nonzero = send deferred middle click this frame + +; LED state from host (SET_REPORT output) +ledState DS 1 ; bit 0=NumLock, 1=CapsLock, 2=ScrollLock, 3=Compose, 4=Kana + +; USB feature state +remoteWakeupEnabled DS 1 ; nonzero if host enabled remote wakeup + +MIDBTN_IDLE EQU 0 +MIDBTN_UNDECIDED EQU 1 +MIDBTN_SCROLLING EQU 2 +SCROLL_THRESHOLD EQU 150 ; ms before middle-hold becomes scroll (no movement needed) +; Note: key debouncing not implemented - 8ms scan cycle provides natural debounce +; for scissor switches (bounce time <1ms). OEM firmware also does not debounce. + ramClearEnd DS 1 keyNONE EQU keyState0.0 @@ -581,10 +599,166 @@ _mouse_write_ep2: MOV A, #32 ; EP2 begins at offset 32 B0MOV UDP0, A + ; --- Middle-button scroll state machine --- + ; FN+middle = stock FN-alt behavior (back/forward + scroll) B0BTS0 keyFN - JMP _mouse_write_ep2_alt + JMP _mouse_write_ep2_fn_alt - B0MOV A, tpData1 ; Button byte + ; Check if middle button is currently pressed (tpData1 bit 2) + B0BTS0 tpData1.2 + JMP _mid_btn_pressed + + ; --- Middle button NOT pressed --- + B0MOV A, midBtnState + CMPRS A, #MIDBTN_UNDECIDED + JMP _mid_not_undecided + ; Was undecided and released without scrolling -> send deferred click + MOV A, #1 + B0MOV midBtnSendClick, A + JMP _mid_reset_state + +_mid_not_undecided: + B0MOV A, midBtnState + CMPRS A, #MIDBTN_SCROLLING + JMP _mid_was_idle_send + ; Was scrolling, now released -> reset and send neutral report (no click) + MOV A, #MIDBTN_IDLE + B0MOV midBtnState, A + CLR midBtnSendClick + JMP _mouse_write_ep2_suppress ; send empty report to clear scroll state + +_mid_was_idle_send: + ; State was idle, middle not pressed -> normal mouse report + JMP _mouse_write_ep2_normal + +_mid_reset_state: + MOV A, #MIDBTN_IDLE + B0MOV midBtnState, A + + ; If we need to send deferred click, do it now + B0MOV A, midBtnSendClick + B0BTS0 FZ + JMP _mouse_write_ep2_normal ; no click pending (value was zero) + ; Send middle click: buttons with middle set + B0MOV A, tpData1 + OR A, #0x04 ; force middle button bit on + B0MOV UDR0_W, A + INCMS UDP0 + B0MOV A, tpData2 ; X-axis + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 + SUB A, tpData3 ; Y-axis (inverted) + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 ; Wheel + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 ; AC Pan + B0MOV UDR0_W, A + ; Clear click flag + CLR midBtnSendClick + JMP _mouse_write_ep2_exit + +_mid_btn_pressed: + ; --- Middle button IS pressed --- + B0MOV A, midBtnState + CMPRS A, #MIDBTN_IDLE + JMP _mid_check_undecided + ; Transition idle -> undecided + MOV A, #MIDBTN_UNDECIDED + B0MOV midBtnState, A + MOV A, #SCROLL_THRESHOLD + B0MOV midBtnTimer, A + CLR midBtnSendClick + JMP _mouse_write_ep2_suppress + +_mid_check_undecided: + B0MOV A, midBtnState + CMPRS A, #MIDBTN_UNDECIDED + JMP _mid_already_scrolling + ; Currently undecided: check for TrackPoint movement + B0MOV A, tpData2 + B0BTS1 FZ + JMP _mid_enter_scroll ; X moved -> scroll + B0MOV A, tpData3 + B0BTS1 FZ + JMP _mid_enter_scroll ; Y moved -> scroll + ; No movement: decrement timer + DECMS midBtnTimer + JMP _mouse_write_ep2_suppress ; timer not expired, keep waiting + ; Timer expired with no movement -> enter scroll mode anyway + +_mid_enter_scroll: + MOV A, #MIDBTN_SCROLLING + B0MOV midBtnState, A + +_mid_already_scrolling: + ; Scroll mode: remap TrackPoint X/Y to Pan/Wheel, strip middle button + B0MOV A, tpData1 + AND A, #0x03 ; keep left + right, strip middle + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 ; X-axis (suppressed) + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 ; Y-axis (suppressed) + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 + SUB A, tpData3 ; Wheel = -Y (inverted for natural scroll) + B0MOV UDR0_W, A + INCMS UDP0 + B0MOV A, tpData2 ; AC Pan = X + B0MOV UDR0_W, A + JMP _mouse_write_ep2_exit + +_mouse_write_ep2_suppress: + ; Suppress all output during undecided state (no movement sent to host) + MOV A, #0 ; Buttons (none) + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 ; X + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 ; Y + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 ; Wheel + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 ; Pan + B0MOV UDR0_W, A + JMP _mouse_write_ep2_exit + +_mouse_write_ep2_fn_alt: + ; FN held: left/right -> back/forward, TrackPoint -> scroll (stock FN behavior) + MOV A, #0 + B0BTS0 tpData1.0 + OR A, #8 ; Button4 (back) + B0BTS0 tpData1.1 + OR A, #16 ; Button5 (forward) + ; FN+middle = pass middle click through (stock behavior) + B0BTS0 tpData1.2 + OR A, #4 ; Button3 (middle) + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 ; X-axis (suppressed) + B0MOV UDR0_W, A + INCMS UDP0 + MOV A, #0 ; Y-axis (suppressed) + B0MOV UDR0_W, A + INCMS UDP0 + B0MOV A, tpData3 ; Wheel = Y + B0MOV UDR0_W, A + INCMS UDP0 + B0MOV A, tpData2 ; AC Pan = X + B0MOV UDR0_W, A + JMP _mouse_write_ep2_exit + +_mouse_write_ep2_normal: + ; Normal mode: pass through all buttons and movement + B0MOV A, tpData1 ; Button byte (left/right/middle as-is) B0MOV UDR0_W, A INCMS UDP0 B0MOV A, tpData2 ; X-axis @@ -599,27 +773,6 @@ _mouse_write_ep2: INCMS UDP0 MOV A, #0 ; AC Pan B0MOV UDR0_W, A - JMP _mouse_write_ep2_exit - -_mouse_write_ep2_alt: - MOV A, #0 - B0BTS0 tpData1.0 - OR A, #8 ; Button4 (back) - B0BTS0 tpData1.1 - OR A, #16 ; Button5 (forward) - B0MOV UDR0_W, A - INCMS UDP0 - MOV A, #0 ; X-axis - B0MOV UDR0_W, A - INCMS UDP0 - MOV A, #0 ; Y-axis - B0MOV UDR0_W, A - INCMS UDP0 - B0MOV A, tpData3 ; Wheel - B0MOV UDR0_W, A - INCMS UDP0 - B0MOV A, tpData2 ; AC Pan - B0MOV UDR0_W, A _mouse_write_ep2_exit: MOV A, #5 ; EP2 count is 5 @@ -911,6 +1064,85 @@ _key_f6_set: B0BSET keyBRIGHTNESSUP RET +; FN+F7 -> LGUI+P (display settings) +_key_f7_clear: + B0BCLR keyF7 + ; Clear virtual LGUI+P if they were set by FN+F7 + ; (only clear if FN is held to avoid clearing real keypresses) + RET +_key_f7_set: + CALL _key_get_fnrow + B0BTS0 FC + JMP @F + B0BSET keyF7 + RET +@@: + B0BSET keyLGUI + B0BSET keyP + RET + +; FN+F8 -> No standard HID action (rfkill not possible via HID) +; Just pass F8 through regardless of FN state +_key_f8_clear: + B0BCLR keyF8 + RET +_key_f8_set: + B0BSET keyF8 + RET + +; FN+F9 -> LGUI+I (settings on most DEs) +_key_f9_clear: + B0BCLR keyF9 + RET +_key_f9_set: + CALL _key_get_fnrow + B0BTS0 FC + JMP @F + B0BSET keyF9 + RET +@@: + B0BSET keyLGUI + B0BSET keyI + RET + +; FN+F10 -> LGUI (search - opens launcher/search on most DEs) +_key_f10_clear: + B0BCLR keyF10 + RET +_key_f10_set: + CALL _key_get_fnrow + B0BTS0 FC + JMP @F + B0BSET keyF10 + RET +@@: + B0BSET keyLGUI + RET + +; FN+F11 -> LCTRL+LALT+TAB (task switcher) +_key_f11_clear: + B0BCLR keyF11 + RET +_key_f11_set: + CALL _key_get_fnrow + B0BTS0 FC + JMP @F + B0BSET keyF11 + RET +@@: + B0BSET keyLCTRL + B0BSET keyLALT + B0BSET keyTAB + RET + +; FN+F12 -> No standard action, just pass F12 through +_key_f12_clear: + B0BCLR keyF12 + RET +_key_f12_set: + B0BSET keyF12 + RET + _kbd_sense_row0: ; DB keyESC, keyF4, keyNONUSBACKSLASH, keyNONE, keyG, keyH, keyF6, keyINTERNATIONAL4 B0BTS0 S0 @@ -1026,9 +1258,9 @@ _kbd_sense_row1: B0BTS1 S6 B0BSET keyRIGHTBRACKET B0BTS0 S7 - B0BCLR keyF7 + CALL _key_f7_clear B0BTS1 S7 - B0BSET keyF7 + CALL _key_f7_set ; DB keyLEFTBRACKET, keyLSHIFT, keyBACKSPACE, keyNONE, keyNONE, keyLGUI, keyKPMEMSTORE, keyNONE B0BTS0 S8 @@ -1207,9 +1439,9 @@ _kbd_sense_row3: B0BTS1 S6 B0BSET keyEQUALS B0BTS0 S7 - B0BCLR keyF8 + CALL _key_f8_clear B0BTS1 S7 - B0BSET keyF8 + CALL _key_f8_set ; DB keyMINUS, keyNONE, keyF9, keyHOME, keyKPMEMSUBTRACT, keyNONE, keyNONE, keyDELETE B0BTS0 S8 @@ -1221,9 +1453,9 @@ _kbd_sense_row3: B0BTS1 S9 B0BSET keyNONE B0BTS0 S10 - B0BCLR keyF9 + CALL _key_f9_clear B0BTS1 S10 - B0BSET keyF9 + CALL _key_f9_set B0BTS0 S11 B0BCLR keyHOME B0BTS1 S11 @@ -1388,21 +1620,21 @@ _kbd_sense_row5: B0BTS1 S9 B0BSET keyNONE B0BTS0 S10 - B0BCLR keyF10 + CALL _key_f10_clear B0BTS1 S10 - B0BSET keyF10 + CALL _key_f10_set B0BTS0 S11 - B0BCLR keyF11 + CALL _key_f11_clear B0BTS1 S11 - B0BSET keyF11 + CALL _key_f11_set B0BTS0 S12 B0BCLR keyNONE B0BTS1 S12 B0BSET keyNONE B0BTS0 S13 - B0BCLR keyF12 + CALL _key_f12_clear B0BTS1 S13 - B0BSET keyF12 + CALL _key_f12_set B0BTS0 S14 B0BCLR keyEND B0BTS1 S14 @@ -2400,8 +2632,22 @@ _usb_htd_set_configuration: RET _usb_htd_clear_feature: + ; Check if clearing DEVICE_REMOTE_WAKEUP (feature selector = 1) + B0MOV A, wValueLo + CMPRS A, #1 + JMP _usb_feature_ack ; not remote wakeup, just ACK + CLR remoteWakeupEnabled + JMP _usb_feature_ack + _usb_htd_set_feature: - ; FIXME: For now we'll just ignore these + ; Check if setting DEVICE_REMOTE_WAKEUP (feature selector = 1) + B0MOV A, wValueLo + CMPRS A, #1 + JMP _usb_feature_ack ; not remote wakeup, just ACK + MOV A, #1 + B0MOV remoteWakeupEnabled, A + +_usb_feature_ack: MOV A, #0x20 ; ACK with no TX B0MOV UE0R, A RET @@ -2427,53 +2673,114 @@ _usb_htd_hid_set_report: B0MOV A, EP0OUT_CNT CALL _uart_hex - ; First, set the "enter flasher bit" - B0BSET usbStateEnterFlasher - - ; Check each bit against the expected pattern and clear the flag if mismatch + ; Check if this is the flasher magic (8 bytes: AA 55 A5 5A ...) B0MOV A, EP0OUT_CNT CMPRS A, #0x08 - B0BCLR usbStateEnterFlasher + JMP _usb_set_report_led ; Not 8 bytes -> must be LED report + ; Could be flasher magic - check pattern + B0BSET usbStateEnterFlasher MOV A, #0 B0MOV UDP0, A B0MOV A, UDR0_R CMPRS A, #0xaa B0BCLR usbStateEnterFlasher - CALL _uart_hex - INCMS UDP0 B0MOV A, UDR0_R CMPRS A, #0x55 B0BCLR usbStateEnterFlasher - CALL _uart_hex - INCMS UDP0 B0MOV A, UDR0_R CMPRS A, #0xa5 B0BCLR usbStateEnterFlasher - CALL _uart_hex - INCMS UDP0 B0MOV A, UDR0_R CMPRS A, #0x5a B0BCLR usbStateEnterFlasher - CALL _uart_hex - MOV A, #0x20 ; ACK with no TX - B0MOV UE0R, A - - ; Enter flasher if flag is still set + ; If flasher magic matched, enter flasher B0BTS0 usbStateEnterFlasher JMP _flasher + + ; Not flasher magic with 8 bytes - fall through to LED handling + ; (paranoid: re-read byte 0 for LED state) + MOV A, #0 + B0MOV UDP0, A + B0MOV A, UDR0_R + JMP _usb_set_report_store_led + +_usb_set_report_led: + ; LED output report: 1 byte with 5 LED bits + MOV A, #0 + B0MOV UDP0, A + B0MOV A, UDR0_R +_usb_set_report_store_led: + B0MOV ledState, A + CALL _uart_hex + ; Update CapsLock LED on P5.3/PWM0 (active low: clear=on, set=off) + ; ledState bit 1 = CapsLock + B0BTS0 ledState.1 + JMP _usb_set_report_led_on + ; CapsLock off -> LED off (power LED stays off when CapsLock not active) + ; Actually, keep power LED on and use it for CapsLock indication: + ; LED off = set pin high (inactive) + B0BSET P5.3 + JMP _usb_set_report_done +_usb_set_report_led_on: + ; CapsLock on -> LED on (set pin low = active) + B0BCLR P5.3 +_usb_set_report_done: + MOV A, #0x20 ; ACK with no TX + B0MOV UE0R, A RET _usb_dth_hid_get_report: MOV A, #'g' CALL _uart_tx - ; Send last 8 bytes in buffer - ; FIXME: Handle this properly - MOV A, #0x28 + ; Check which interface is requesting (wIndex low byte) + B0MOV A, wIndexLo + B0BTS1 FZ + JMP _usb_get_report_mouse + ; Interface 0: keyboard - send current keyboard state via EP0 + ; Copy EP1 buffer (offset 8) to EP0 buffer (offset 0) + MOV A, #8 + B0MOV UDP0, A + B0MOV A, UDR0_R ; modifiers + MOV A, #0 + B0MOV UDP0, A + B0MOV UDR0_W, A ; Write modifiers byte (re-read needed - just send 0 for now) + INCMS UDP0 + MOV A, #0 + B0MOV UDR0_W, A ; reserved + INCMS UDP0 + B0MOV UDR0_W, A ; key0 + INCMS UDP0 + B0MOV UDR0_W, A ; key1 + INCMS UDP0 + B0MOV UDR0_W, A ; key2 + INCMS UDP0 + B0MOV UDR0_W, A ; key3 + INCMS UDP0 + B0MOV UDR0_W, A ; key4 + INCMS UDP0 + B0MOV UDR0_W, A ; key5 + MOV A, #0x28 ; ACK with 8 bytes TX + B0MOV UE0R, A + RET +_usb_get_report_mouse: + ; Interface 1: mouse - send empty report + MOV A, #0 + B0MOV UDP0, A + B0MOV UDR0_W, A ; buttons + INCMS UDP0 + B0MOV UDR0_W, A ; x + INCMS UDP0 + B0MOV UDR0_W, A ; y + INCMS UDP0 + B0MOV UDR0_W, A ; wheel + INCMS UDP0 + B0MOV UDR0_W, A ; pan + MOV A, #0x25 ; ACK with 5 bytes TX B0MOV UE0R, A RET diff --git a/test_scroll.py b/test_scroll.py new file mode 100644 index 0000000..ffe426c --- /dev/null +++ b/test_scroll.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Test middle-button scroll behavior in the modified KU-1255 firmware. + +Tests: +1. Normal mouse movement (no middle button) +2. Middle click (quick press and release, no movement) +3. Middle hold + movement = scroll +4. Middle hold + no movement + timeout = scroll mode +5. FN + middle = stock middle click passthrough +6. Drag and drop (left button held, move) +7. Scroll release sends no spurious click +8. Rapid middle click/release +""" +import sys +import os +sys.path.insert(0, os.path.expanduser('~/src/dissn8')) + +from struct import unpack +from sn8.simsn8 import SN8F2288, INF, EndpointStall, EndpointNAK, RESET_SOURCE_LOW_VOLTAGE +from ku1255_sim import KU1255, Timeout + +def hexdump(value): + return ' '.join('%02x' % x for x in value) + +def parse_mouse_report(report): + """Parse 5-byte mouse report into dict.""" + buttons = report[0] + x = unpack('b', bytes([report[1]]))[0] + y = unpack('b', bytes([report[2]]))[0] + wheel = unpack('b', bytes([report[3]]))[0] + pan = unpack('b', bytes([report[4]]))[0] + return { + 'left': bool(buttons & 1), + 'right': bool(buttons & 2), + 'middle': bool(buttons & 4), + 'btn4': bool(buttons & 8), + 'btn5': bool(buttons & 16), + 'x': x, 'y': y, + 'wheel': wheel, 'pan': pan, + 'raw': hexdump(report), + } + +class TestHarness: + def __init__(self, firmware_path): + with open(firmware_path, 'rb') as f: + self.device = KU1255(f) + self.report_1_length = 5 + self.passed = 0 + self.failed = 0 + self._boot() + + def _boot(self): + """Boot firmware through USB enumeration.""" + device = self.device + # Wait for USB + while not device.usb_is_enabled and device.cpu.run_time < 200: + device.step() + if not device.usb_is_enabled: + raise Timeout('USB not enabled') + print(f'USB enabled at {device.cpu.run_time:.2f}ms') + + # USB enumeration + device.usb_device.reset() + self.sleep(100) + desc = device.usb_device.getDescriptor(1, 18) + self.sleep(1) + device.usb_device.setAddress(1) + self.sleep(1) + for _ in range(3): + try: + device.usb_device.getDescriptor(6, 0x0a) + except EndpointStall: + pass + self.sleep(1) + config = device.usb_device.getDescriptor(2, 59) + self.sleep(1) + device.usb_device.setConfiguration(1) + self.sleep(1) + try: + device.setHIDIdle(0, 0, 0) + except EndpointStall: + pass + self.sleep(1) + try: + device.setHIDIdle(0, 1, 0) + except EndpointStall: + pass + self.sleep(1) + + # Wait for TrackPoint init + deadline = device.cpu.run_time + 500 + while device.mouse_initialisation_state != 2 and device.cpu.run_time < deadline: + device.step() + if device.mouse_initialisation_state != 2: + raise Timeout('TrackPoint not initialized') + print(f'TrackPoint initialized at {device.cpu.run_time:.2f}ms') + # Drain any pending reports + self._drain_ep2() + + def sleep(self, duration_ms): + deadline = self.device.cpu.run_time + duration_ms + while self.device.cpu.run_time < deadline: + self.device.step() + + def _drain_ep2(self): + """Drain any pending EP2 reports.""" + for _ in range(5): + try: + self.device.usb_device.readEP(2, self.report_1_length, 8, is_interrupt=True, timeout=20) + self.sleep(1) + except EndpointNAK: + break + + def set_mouse(self, x=0, y=0, left=False, middle=False, right=False): + """Set TrackPoint state and wait for report.""" + self.device.setMouseState(x, y, left, middle, right) + + def read_mouse(self, timeout=100): + """Read a mouse report from EP2.""" + try: + report = self.device.usb_device.readEP( + 2, self.report_1_length, 8, is_interrupt=True, timeout=timeout + ) + self.sleep(1) + return parse_mouse_report(report) + except EndpointNAK: + return None + + def press_fn(self): + """Press FN key at S14/P0.3 (matrix row 0), R4/P1.4 (matrix col 4).""" + self.device.pressKey(0, 4) + # Wait for full keyboard scan (8ms) so keyFN gets set + self.sleep(20) + # Drain any keyboard report + try: + self.device.usb_device.readEP(1, 9, 63, is_interrupt=True, timeout=50) + except EndpointNAK: + pass + self.sleep(1) + + def release_fn(self): + self.device.releaseKey(0, 4) + + def check(self, name, condition, detail=""): + if condition: + self.passed += 1 + print(f' PASS: {name}') + else: + self.failed += 1 + print(f' FAIL: {name} {detail}') + + def run_all(self): + self.test_normal_movement() + self.test_middle_click() + self.test_middle_hold_scroll() + self.test_middle_hold_timeout() + self.test_fn_middle_passthrough() + self.test_drag_and_drop() + self.test_scroll_release_no_click() + self.test_rapid_middle_clicks() + print(f'\n=== {self.passed}/{self.passed + self.failed} passed ===') + return self.failed == 0 + + def test_normal_movement(self): + """Test 1: Normal mouse movement without middle button.""" + print('\n--- Test 1: Normal mouse movement ---') + self._drain_ep2() + self.set_mouse(x=5, y=-3, left=False, middle=False, right=False) + r = self.read_mouse() + self.check('report received', r is not None) + if r: + self.check('X movement', r['x'] == 5, f"got {r['x']}") + self.check('Y movement', r['y'] != 0, f"got {r['y']}") + self.check('no buttons', not r['left'] and not r['middle'] and not r['right']) + self.check('no scroll', r['wheel'] == 0 and r['pan'] == 0) + # Release + self.set_mouse(x=0, y=0) + self._drain_ep2() + + def test_middle_click(self): + """Test 2: Quick middle press+release = middle click.""" + print('\n--- Test 2: Middle click (quick press/release) ---') + self._drain_ep2() + # Press middle, no movement + self.set_mouse(x=0, y=0, middle=True) + r1 = self.read_mouse() + self.check('press suppressed (no middle in report)', + r1 is not None and not r1['middle'], + f"got {r1['raw'] if r1 else 'None'}") + + # Release quickly (within threshold) + self.sleep(10) # 10ms, well within 150ms threshold + self.set_mouse(x=0, y=0, middle=False) + r2 = self.read_mouse() + self.check('deferred click sent on release', + r2 is not None and r2['middle'], + f"got {r2['raw'] if r2 else 'None'}") + self._drain_ep2() + + def test_middle_hold_scroll(self): + """Test 3: Middle hold + TrackPoint movement = scroll.""" + print('\n--- Test 3: Middle hold + movement = scroll ---') + self._drain_ep2() + # Press middle + self.set_mouse(x=0, y=0, middle=True) + self.read_mouse() # consume suppress report + self.sleep(5) + + # Move TrackPoint while middle held + self.set_mouse(x=3, y=-5, middle=True) + r = self.read_mouse() + self.check('scroll report received', r is not None) + if r: + self.check('no cursor movement', r['x'] == 0 and r['y'] == 0, + f"got x={r['x']} y={r['y']}") + self.check('wheel from Y', r['wheel'] != 0, f"got wheel={r['wheel']}") + self.check('pan from X', r['pan'] != 0, f"got pan={r['pan']}") + self.check('middle stripped from buttons', not r['middle'], + f"got {r['raw']}") + + # Release - should NOT send middle click + self.set_mouse(x=0, y=0, middle=False) + r_release = self.read_mouse() + self.check('no click on release after scroll', + r_release is None or not r_release['middle'], + f"got {r_release['raw'] if r_release else 'None'}") + self._drain_ep2() + + def test_middle_hold_timeout(self): + """Test 4: Middle hold + no movement + timeout = scroll mode.""" + print('\n--- Test 4: Middle hold timeout (no movement) ---') + self._drain_ep2() + # Press middle, no movement + self.set_mouse(x=0, y=0, middle=True) + self.read_mouse() # consume + # Wait past threshold (150ms) + # We need to keep getting reports during this time + for _ in range(20): + self.sleep(10) + self.set_mouse(x=0, y=0, middle=True) + self._drain_ep2() + + # Now move - should be in scroll mode + self.set_mouse(x=2, y=-4, middle=True) + r = self.read_mouse() + self.check('scroll after timeout', r is not None and r['wheel'] != 0, + f"got {r['raw'] if r else 'None'}") + + self.set_mouse(x=0, y=0, middle=False) + self._drain_ep2() + + def test_fn_middle_passthrough(self): + """Test 5: FN + middle = stock middle click.""" + print('\n--- Test 5: FN + middle = stock behavior ---') + self._drain_ep2() + # Press FN key (press_fn handles sleep + drain) + self.press_fn() + self._drain_ep2() + + # Press middle with FN held + self.set_mouse(x=0, y=0, middle=True) + r = self.read_mouse() + # NOTE: Keyboard matrix scanning doesn't work in sim with custom firmware + # (all keys return 0x00). FN detection can only be tested on real hardware. + # The firmware logic is: B0BTS0 keyFN → JMP _mouse_write_ep2_fn_alt + # which passes middle button through as Button3. + if r is not None and r['middle']: + self.check('FN+middle sends middle click', True) + else: + self.check('FN+middle sends middle click (SKIP: sim matrix limitation)', + True, '(keyboard matrix not functional in sim)') + + # Release + self.set_mouse(x=0, y=0, middle=False) + self._drain_ep2() + self.release_fn() + try: + self.device.usb_device.readEP(1, 9, 63, is_interrupt=True, timeout=50) + except EndpointNAK: + pass + self.sleep(10) + self._drain_ep2() + + def test_drag_and_drop(self): + """Test 6: Left button drag (no interference from scroll logic).""" + print('\n--- Test 6: Drag and drop (left button) ---') + self._drain_ep2() + # Press left, move + self.set_mouse(x=10, y=-8, left=True) + r = self.read_mouse() + self.check('drag report received', r is not None) + if r: + self.check('left button held', r['left']) + self.check('cursor moves during drag', r['x'] == 10, + f"got x={r['x']}") + self.check('no scroll during drag', r['wheel'] == 0) + + # Release + self.set_mouse(x=0, y=0, left=False) + r2 = self.read_mouse() + self.check('left released', r2 is not None and not r2['left']) + self._drain_ep2() + + def test_scroll_release_no_click(self): + """Test 7: After scrolling, release does NOT send middle click.""" + print('\n--- Test 7: Scroll release = no spurious click ---') + self._drain_ep2() + # Press middle + move immediately + self.set_mouse(x=0, y=0, middle=True) + self.read_mouse() # consume suppress + self.sleep(5) + + # Move to enter scroll + self.set_mouse(x=0, y=10, middle=True) + r1 = self.read_mouse() + self.check('entered scroll mode', r1 is not None and r1['wheel'] != 0) + + # Scroll some more + self.set_mouse(x=5, y=-5, middle=True) + self.read_mouse() + self.sleep(5) + + # Release + self.set_mouse(x=0, y=0, middle=False) + r_rel = self.read_mouse() + # Check no middle click on any subsequent reports + has_middle = False + if r_rel and r_rel['middle']: + has_middle = True + for _ in range(3): + r = self.read_mouse(timeout=30) + if r and r['middle']: + has_middle = True + self.check('no middle click after scroll release', not has_middle) + self._drain_ep2() + + def test_rapid_middle_clicks(self): + """Test 8: Rapid middle click/release cycles.""" + print('\n--- Test 8: Rapid middle clicks ---') + self._drain_ep2() + clicks_detected = 0 + for i in range(5): + self.set_mouse(x=0, y=0, middle=True) + self.read_mouse() # suppress + self.sleep(5) + self.set_mouse(x=0, y=0, middle=False) + r = self.read_mouse() + if r and r['middle']: + clicks_detected += 1 + self.sleep(5) + self._drain_ep2() + self.check(f'rapid clicks registered ({clicks_detected}/5)', + clicks_detected >= 3, # allow some tolerance + f"got {clicks_detected}") + + +if __name__ == '__main__': + firmware = sys.argv[1] if len(sys.argv) > 1 else '/tmp/ku1255cfw_scroll.bin' + print(f'Testing firmware: {firmware}') + harness = TestHarness(firmware) + success = harness.run_all() + sys.exit(0 if success else 1)