From: Luiz Augusto von Dentz <luiz.dentz@gmail.com>
To: linux-bluetooth@vger.kernel.org
Subject: [PATCH BlueZ v5 07/21] test: functional: add HoG tests
Date: Mon, 28 Sep 2026 13:32:26 -0400 [thread overview]
Message-ID: <20260928173243.1073509-8-luiz.dentz@gmail.com> (raw)
In-Reply-To: <20260928173243.1073509-1-luiz.dentz@gmail.com>
From: Luiz Augusto von Dentz <luiz.von.dentz@intel.com>
Add tests where bluetoothctl registers a HID Service, with and without
SCI support, using client/scripts/hog-device*.bt, and the HID host
checks the service, receives Input Reports and, with SCI support,
changes the SCI mode and the connection rate.
See doc/functional-hog.rst for details.
Assisted-by: OpenCode:claude-opus-5.5
---
test/functional/test_hog.py | 252 ++++++++++++++++++++++++++++++++++++
1 file changed, 252 insertions(+)
create mode 100644 test/functional/test_hog.py
diff --git a/test/functional/test_hog.py b/test/functional/test_hog.py
new file mode 100644
index 000000000000..ea54882d059d
--- /dev/null
+++ b/test/functional/test_hog.py
@@ -0,0 +1,252 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+Tests for HID over GATT (HoG) using bluetoothctl in VM instances
+
+The HID device (host1) registers a HID Service (HIDS) with bluetoothctl,
+using client/scripts/hog-device.bt or client/scripts/hog-device-sci.bt,
+and the HID host (host0) pairs with it and checks the service over GATT.
+"""
+
+import warnings
+
+import pytest
+
+from pytest_bluezenv import Bluetoothd, Pexpect, find_exe, host_config
+from pytest_bluezenv.utils import bluez_src_dir
+
+pytestmark = [pytest.mark.vm]
+
+# The HID Service is claimed by the input plugin of the HID host, so it
+# has to be exported read-write for bluetoothctl to write HID SCI Mode
+HOG_CONF = """[General]
+ControllerMode = le
+
+[GATT]
+ExportClaimedServices = read-write
+"""
+
+HIDS_UUID = "00001812-0000-1000-8000-00805f9b34fb"
+
+# Local attributes registered by client/scripts/hog-device*.bt
+LOCAL_REPORT = "/org/bluez/app/service0/chrc2"
+LOCAL_SCI_MODE = "/org/bluez/app/service0/chrc5"
+
+# Keyboard Input Reports: Modifiers, Reserved, then 6 Key Codes
+REPORTS = [
+ "00 00 04 00 00 00 00 00", # a pressed
+ "02 00 05 00 00 00 00 00", # Left Shift + b pressed
+ "00 00 00 00 00 00 00 00", # released
+]
+
+# HID SCI Mode: Fast Mode
+SCI_FAST_MODE = "03"
+
+# LE Connection Rate parameters requested with mgmt.conn-subrate once in
+# SCI Fast Mode: interval 1 ms to 2 ms (units of 0.125 ms), within the
+# range given in HID SCI Information, no subrating, no latency and 5 s
+# supervision timeout (units of 10 ms)
+SCI_RATE = ["0x0008", "0x0010", "1", "1", "0", "0", "0x01f4"]
+
+# Reported when an operation cannot complete, so a test does not have to
+# wait for its timeout to know it is not going to
+FAILURES = [
+ r"(Failed to \w+[^\r\n]*)",
+ r"(Device \S+ not available)",
+ r"(No device connected)",
+ r"(No attribute selected)",
+]
+
+# What a command reports is printed as it runs, unlike what the peers
+# report over the air, so waiting the default timeout for it only makes
+# a failure slower
+REPLY_TIMEOUT = 5
+
+
+def script(name):
+ src = bluez_src_dir()
+ if src is None:
+ pytest.skip("BlueZ source directory not known")
+
+ path = src / "client" / "scripts" / name
+ if not path.exists():
+ pytest.skip(f"{path} not found")
+
+ return str(path)
+
+
+def spawn_bluetoothctl(host, init_script=None):
+ exe = find_exe("client", "bluetoothctl")
+ # Accept pairing and authorize services without prompting, with a
+ # capability pairing Just Works, as there is no one to answer the
+ # entry of a passkey
+ args = [exe, "-a", "auto:NoInputNoOutput"]
+ if init_script:
+ args += ["--init-script", script(init_script)]
+ return host.pexpect.spawn(args)
+
+
+def expect(ctl, patterns, **kwargs):
+ """
+ Expect one of the patterns, failing as soon as one of the failures
+ shows up. Return the index of the pattern matched and its groups.
+ """
+ if isinstance(patterns, str):
+ patterns = [patterns]
+
+ idx, m = ctl.expect(FAILURES + list(patterns), **kwargs)
+ if idx < len(FAILURES):
+ raise AssertionError(m[0].decode("utf-8") if m else "failed")
+
+ return idx - len(FAILURES), m
+
+
+def expect_all(ctl, patterns, **kwargs):
+ """Expect all the given patterns, in any order."""
+ pending = list(patterns)
+
+ while pending:
+ idx, _ = expect(ctl, pending, **kwargs)
+ pending.pop(idx)
+
+
+def pair_le(host0, ctl0, host1, ctl1):
+ ctl0.send("scan on\n")
+ expect(ctl0, f"Controller {host0.bdaddr.upper()} Discovering: yes")
+
+ ctl1.send("advertise on\n")
+ expect(ctl1, "Advertising object registered")
+
+ expect(ctl0, f"Device {host1.bdaddr.upper()}")
+ ctl0.send(f"pair {host1.bdaddr.upper()}\n")
+
+ # See test_bluetoothctl_pair_le: passkey confirmation is handled by
+ # the auto agent, but legacy passkey entry still needs an answer
+ legacy = r"\[agent\].*Passkey:.*m(\d+)"
+ pending = [
+ r"Pairing successful",
+ f"Device {host1.bdaddr.upper()} ServicesResolved: yes",
+ ]
+
+ while pending:
+ idx, m = expect(ctl0, [legacy] + pending)
+ if idx == 0:
+ warnings.warn(
+ "BUG: we got passkey authentication, bluetoothd/kernel "
+ "should be fixed"
+ )
+ ctl1.expect(r"\[agent\] Enter passkey \(number in 0-999999\):")
+ ctl1.send(f"{m[0].decode('utf-8')}\n")
+ continue
+ pending.pop(idx - 1)
+
+ ctl0.send("scan off\n")
+
+
+def read_attribute(ctl, uuid):
+ """Read the given remote attribute, returning its value as hex string."""
+ ctl.send(f"gatt.select-attribute {uuid}\n")
+ ctl.send("gatt.read\n")
+ expect(ctl, r"Attempting to read \S+", timeout=REPLY_TIMEOUT)
+ return expect_hexdump(ctl)
+
+
+def hexbytes(value):
+ """Turn a hex string into the format taken by gatt.write."""
+ return " ".join(f"0x{byte}" for byte in value.split())
+
+
+def expect_hexdump(ctl, **kwargs):
+ """Expect a value printed by bluetoothctl, returning it as hex string."""
+ _, m = expect(ctl, r"((?: [0-9a-f]{2})+) ", **kwargs)
+ return m[0].decode("utf-8").strip()
+
+
+def expect_notification(ctl):
+ """Expect a notification of the remote attribute, returning its value."""
+ expect(ctl, rf"CHG.*? Attribute /\S+ Value:")
+ return expect_hexdump(ctl)
+
+
+def enable_notifications(ctl, device, uuid, local):
+ """Enable notifications of the given attribute, on the HID host."""
+ ctl.send(f"gatt.select-attribute {uuid}\n")
+ ctl.send("gatt.notify on\n")
+ expect(ctl, r"Notify started", timeout=REPLY_TIMEOUT)
+ # Either subscribed with StartNotify, or with AcquireNotify as done by
+ # the input plugin of the HID host for the Input Reports
+ expect(
+ device,
+ rf"Attribute {local} (\S+ )?(notifications enabled|Notify sock acquired)",
+ )
+
+
+def notify(device, local, value):
+ """Notify the given value of a local attribute, on the HID device."""
+ device.send(f"gatt.select-attribute local {local}\n")
+ device.send(f'gatt.write "{hexbytes(value)}"\n')
+ expect(device, rf"Attribute {local} .*written", timeout=REPLY_TIMEOUT)
+
+
+@host_config(
+ [Bluetoothd(conf=HOG_CONF), Pexpect()],
+ [Bluetoothd(conf=HOG_CONF), Pexpect()],
+)
+@pytest.mark.parametrize(
+ "init_script, flags, sci",
+ [
+ ("hog-device.bt", "02", None),
+ ("hog-device-sci.bt", "06", ("00", "08 01 08 00 50 00 08 00")),
+ ],
+ ids=["no-sci", "sci"],
+)
+def test_hog(hosts, init_script, flags, sci):
+ host0, host1 = hosts
+
+ device = spawn_bluetoothctl(host1, init_script)
+ expect(device, "Application registered")
+
+ ctl = spawn_bluetoothctl(host0)
+ pair_le(host0, ctl, host1, device)
+
+ ctl.send(f"info {host1.bdaddr.upper()}\n")
+ expect(ctl, rf"Human Interface Device\s+\({HIDS_UUID}\)", timeout=REPLY_TIMEOUT)
+
+ # HID Information: bcdHID 1.11, bCountryCode 0x00 and Flags
+ assert read_attribute(ctl, "2a4a") == f"11 01 00 {flags}"
+
+ # Input Reports: the HID device notifies a few key presses
+ enable_notifications(ctl, device, "2a4d", LOCAL_REPORT)
+
+ for report in REPORTS:
+ notify(device, LOCAL_REPORT, report)
+ assert expect_notification(ctl) == report
+
+ if sci is None:
+ return
+
+ mode, info = sci
+ assert read_attribute(ctl, "2c39") == mode
+ assert read_attribute(ctl, "2c3a") == info
+
+ # SCI mode change: the HID host writes the new mode to the HID device
+ enable_notifications(ctl, device, "2c39", LOCAL_SCI_MODE)
+
+ ctl.send(f'gatt.write "{hexbytes(SCI_FAST_MODE)}"\n')
+ expect(device, rf"\[{LOCAL_SCI_MODE} .*\] WriteValue:")
+ assert expect_hexdump(device) == SCI_FAST_MODE
+
+ # The HID host, as central, changes the connection rate accordingly
+ ctl.send(f"mgmt.conn-subrate {host1.bdaddr} {' '.join(SCI_RATE)}\n")
+ # The connection rate may change before the command completes, so the
+ # event may be printed before the reply
+ rate = rf"{{}} type .* connection subrate interval {SCI_RATE[0]}"
+ expect_all(
+ ctl,
+ [r"Connection Subrate loaded successfully", rate.format(host1.bdaddr.upper())],
+ )
+ expect(device, rate.format(host0.bdaddr.upper()))
+
+ # Then the HID device confirms the mode has been changed
+ notify(device, LOCAL_SCI_MODE, SCI_FAST_MODE)
+ assert expect_notification(ctl) == SCI_FAST_MODE
--
2.55.0
next prev parent reply other threads:[~2026-09-28 17:33 UTC|newest]
Thread overview: 22+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-09-28 17:32 [PATCH BlueZ v5 00/21] Add HoG functional tests and shared/hog Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 01/21] shared/gatt-client: Fix calling destroy after unregistering notify Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 02/21] client/gatt: Fix setting descriptor value from scripts Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 03/21] client/mgmt: Print Connection Subrate event Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 04/21] emulator: Default to the latest BR/EDR+LE version Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 05/21] client/scripts: Add HoG device scripts Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 06/21] doc: Add functional-hog documentation Luiz Augusto von Dentz
2026-09-28 17:32 ` Luiz Augusto von Dentz [this message]
2026-09-28 17:32 ` [PATCH BlueZ v5 08/21] test: functional: limit the workers by the memory available Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 09/21] client/agent: Fix crash on Cancel with no pending request Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 10/21] shared/uhid: Fix size of Get Report reply with a Report ID Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 11/21] shared/uhid: Keep reading when an event is not available Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 12/21] shared/tester: Allow expecting a PDU with no response Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 13/21] shared/hog: Add initial implementation Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 14/21] unit/test-hog: Use shared/hog Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 15/21] test: functional: change the HoG SCI mode with the HID Control Point Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 16/21] input/hog: Use shared/hog Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 17/21] doc: Add CONFIG_HIDRAW to the tester kernel config Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 18/21] unit/test-uhid: Add Get Report tests Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 19/21] device: Use bt_att instead of GAttrib Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 20/21] attrib: Remove GAttrib and gatttool Luiz Augusto von Dentz
2026-09-28 17:32 ` [PATCH BlueZ v5 21/21] attrib: Remove directory Luiz Augusto von Dentz
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260928173243.1073509-8-luiz.dentz@gmail.com \
--to=luiz.dentz@gmail.com \
--cc=linux-bluetooth@vger.kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.