* [PATCH BlueZ v3 01/20] emulator: btvirt: check pkt lengths, don't get stuck on malformed
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
@ 2026-03-22 21:29 ` Pauli Virtanen
2026-03-22 22:38 ` Functional/integration testing bluez.test.bot
2026-03-22 21:29 ` [PATCH BlueZ v3 02/20] emulator: btvirt: allow specifying where server unix sockets are made Pauli Virtanen
` (18 subsequent siblings)
19 siblings, 1 reply; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:29 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Don't try to parse packet before whole header is received.
If received data has unknown packet type, reset buffer so that we don't
get stuck.
---
emulator/server.c | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/emulator/server.c b/emulator/server.c
index fa2bc07be..f14e14cd2 100644
--- a/emulator/server.c
+++ b/emulator/server.c
@@ -136,12 +136,20 @@ again:
client->pkt_len = 0;
break;
case HCI_ACLDATA_PKT:
+ if (count < HCI_ACL_HDR_SIZE + 1) {
+ client->pkt_offset += len;
+ return;
+ }
acl_hdr = (hci_acl_hdr*)(ptr + 1);
client->pkt_expect = HCI_ACL_HDR_SIZE + acl_hdr->dlen + 1;
client->pkt_data = malloc(client->pkt_expect);
client->pkt_len = 0;
break;
case HCI_ISODATA_PKT:
+ if (count < HCI_ISO_HDR_SIZE + 1) {
+ client->pkt_offset += len;
+ return;
+ }
iso_hdr = (hci_iso_hdr *)(ptr + 1);
client->pkt_expect = HCI_ISO_HDR_SIZE +
iso_hdr->dlen + 1;
@@ -151,6 +159,7 @@ again:
default:
printf("packet error, unknown type: %d\n",
client->pkt_type);
+ client->pkt_offset = 0;
return;
}
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 02/20] emulator: btvirt: allow specifying where server unix sockets are made
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
2026-03-22 21:29 ` [PATCH BlueZ v3 01/20] emulator: btvirt: check pkt lengths, don't get stuck on malformed Pauli Virtanen
@ 2026-03-22 21:29 ` Pauli Virtanen
2026-03-22 21:29 ` [PATCH BlueZ v3 03/20] emulator: btvirt: support SCO data packets Pauli Virtanen
` (17 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:29 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Make --server to take optional path name where to create the various
server sockets.
---
emulator/main.c | 37 ++++++++++++++++++++++++-------------
1 file changed, 24 insertions(+), 13 deletions(-)
diff --git a/emulator/main.c b/emulator/main.c
index 456fcd98e..09d6e9adb 100644
--- a/emulator/main.c
+++ b/emulator/main.c
@@ -18,6 +18,7 @@
#include <stdbool.h>
#include <getopt.h>
#include <sys/uio.h>
+#include <limits.h>
#include "src/shared/mainloop.h"
#include "src/shared/util.h"
@@ -46,7 +47,7 @@ static void usage(void)
printf("options:\n"
"\t-d Enable debug\n"
"\t-S Create local serial port\n"
- "\t-s Create local server sockets\n"
+ "\t-s[path=/tmp] Create local server sockets\n"
"\t-t[port=45550] Create a TCP server\n"
"\t-l[num] Number of local controllers\n"
"\t-L Create LE only controller\n"
@@ -60,7 +61,7 @@ static void usage(void)
static const struct option main_options[] = {
{ "debug", no_argument, NULL, 'd' },
{ "serial", no_argument, NULL, 'S' },
- { "server", no_argument, NULL, 's' },
+ { "server", optional_argument, NULL, 's' },
{ "tcp", optional_argument, NULL, 't' },
{ "local", optional_argument, NULL, 'l' },
{ "le", no_argument, NULL, 'L' },
@@ -88,6 +89,7 @@ int main(int argc, char *argv[])
struct server *server5;
bool debug_enabled = false;
bool server_enabled = false;
+ const char *server_path = "/tmp";
uint16_t tcp_port = 0;
bool serial_enabled = false;
int letest_count = 0;
@@ -100,7 +102,7 @@ int main(int argc, char *argv[])
for (;;) {
int opt;
- opt = getopt_long(argc, argv, "dSst::l::LBAU::T::vh",
+ opt = getopt_long(argc, argv, "dSs::t::l::LBAU::T::vh",
main_options, NULL);
if (opt < 0)
break;
@@ -114,6 +116,8 @@ int main(int argc, char *argv[])
break;
case 's':
server_enabled = true;
+ if (optarg)
+ server_path = optarg;
break;
case 't':
if (optarg)
@@ -196,28 +200,35 @@ int main(int argc, char *argv[])
}
if (server_enabled) {
- server1 = server_open_unix(SERVER_TYPE_BREDRLE,
- "/tmp/bt-server-bredrle");
+ char path[PATH_MAX];
+
+ snprintf(path, sizeof(path), "%s/%s", server_path,
+ "bt-server-bredrle");
+ server1 = server_open_unix(SERVER_TYPE_BREDRLE, path);
if (!server1)
fprintf(stderr, "Failed to open BR/EDR/LE server\n");
- server2 = server_open_unix(SERVER_TYPE_BREDR,
- "/tmp/bt-server-bredr");
+ snprintf(path, sizeof(path), "%s/%s", server_path,
+ "bt-server-bredr");
+ server2 = server_open_unix(SERVER_TYPE_BREDR, path);
if (!server2)
fprintf(stderr, "Failed to open BR/EDR server\n");
- server3 = server_open_unix(SERVER_TYPE_AMP,
- "/tmp/bt-server-amp");
+ snprintf(path, sizeof(path), "%s/%s", server_path,
+ "bt-server-amp");
+ server3 = server_open_unix(SERVER_TYPE_AMP, path);
if (!server3)
fprintf(stderr, "Failed to open AMP server\n");
- server4 = server_open_unix(SERVER_TYPE_LE,
- "/tmp/bt-server-le");
+ snprintf(path, sizeof(path), "%s/%s", server_path,
+ "bt-server-le");
+ server4 = server_open_unix(SERVER_TYPE_LE, path);
if (!server4)
fprintf(stderr, "Failed to open LE server\n");
- server5 = server_open_unix(SERVER_TYPE_MONITOR,
- "/tmp/bt-server-mon");
+ snprintf(path, sizeof(path), "%s/%s", server_path,
+ "bt-server-mon");
+ server5 = server_open_unix(SERVER_TYPE_MONITOR, path);
if (!server5)
fprintf(stderr, "Failed to open monitor server\n");
}
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 03/20] emulator: btvirt: support SCO data packets
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
2026-03-22 21:29 ` [PATCH BlueZ v3 01/20] emulator: btvirt: check pkt lengths, don't get stuck on malformed Pauli Virtanen
2026-03-22 21:29 ` [PATCH BlueZ v3 02/20] emulator: btvirt: allow specifying where server unix sockets are made Pauli Virtanen
@ 2026-03-22 21:29 ` Pauli Virtanen
2026-03-22 21:29 ` [PATCH BlueZ v3 04/20] emulator: btdev: clear more state on Reset Pauli Virtanen
` (16 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:29 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Support also SCO data packets in btvirt.
---
emulator/server.c | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/emulator/server.c b/emulator/server.c
index f14e14cd2..7790867b7 100644
--- a/emulator/server.c
+++ b/emulator/server.c
@@ -119,6 +119,7 @@ again:
hci_command_hdr *cmd_hdr;
hci_acl_hdr *acl_hdr;
hci_iso_hdr *iso_hdr;
+ hci_sco_hdr *sco_hdr;
if (!client->pkt_data) {
client->pkt_type = ptr[0];
@@ -156,6 +157,17 @@ again:
client->pkt_data = malloc(client->pkt_expect);
client->pkt_len = 0;
break;
+ case HCI_SCODATA_PKT:
+ if (count < HCI_SCO_HDR_SIZE + 1) {
+ client->pkt_offset += len;
+ return;
+ }
+ sco_hdr = (hci_sco_hdr *)(ptr + 1);
+ client->pkt_expect = HCI_SCO_HDR_SIZE +
+ sco_hdr->dlen + 1;
+ client->pkt_data = malloc(client->pkt_expect);
+ client->pkt_len = 0;
+ break;
default:
printf("packet error, unknown type: %d\n",
client->pkt_type);
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 04/20] emulator: btdev: clear more state on Reset
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (2 preceding siblings ...)
2026-03-22 21:29 ` [PATCH BlueZ v3 03/20] emulator: btvirt: support SCO data packets Pauli Virtanen
@ 2026-03-22 21:29 ` Pauli Virtanen
2026-03-22 21:29 ` [PATCH BlueZ v3 05/20] test-runner: enable path argument for --unix Pauli Virtanen
` (15 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:29 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
On controller Reset command, initialize most fields in struct btdev to
zero, similarly to the state just after btdev_create().
This excludes some fields like command bitmasks, which hciemu may have
adjusted.
To make this easier, add struct_group() macro similar to what kernel
uses.
---
emulator/btdev.c | 117 ++++++++++++++++++++++++++++-------------------
1 file changed, 70 insertions(+), 47 deletions(-)
diff --git a/emulator/btdev.c b/emulator/btdev.c
index d3a9c6735..55747d0ca 100644
--- a/emulator/btdev.c
+++ b/emulator/btdev.c
@@ -52,6 +52,12 @@
#define has_bredr(btdev) (!((btdev)->features[4] & 0x20))
#define has_le(btdev) (!!((btdev)->features[4] & 0x40))
+#define struct_group(NAME, MEMBERS...) \
+ union { \
+ struct { MEMBERS }; \
+ struct { MEMBERS } NAME; \
+ }
+
#define ACL_HANDLE BIT(0)
#define SCO_HANDLE BIT(8)
#define CIS_HANDLE SCO_HANDLE
@@ -151,15 +157,6 @@ struct btdev {
struct queue *conns;
- bool auth_init;
- uint8_t link_key[16];
- uint16_t pin[16];
- uint8_t pin_len;
- uint8_t io_cap;
- uint8_t auth_req;
- bool ssp_auth_complete;
- uint8_t ssp_status;
-
btdev_command_func command_handler;
void *command_data;
@@ -198,6 +195,18 @@ struct btdev {
const struct btdev_cmd *emu_cmds;
bool aosp_capable;
+ /* State zeroed on reset */
+ struct_group(reset_group,
+
+ bool auth_init;
+ uint8_t link_key[16];
+ uint16_t pin[16];
+ uint8_t pin_len;
+ uint8_t io_cap;
+ uint8_t auth_req;
+ bool ssp_auth_complete;
+ uint8_t ssp_status;
+
uint16_t default_link_policy;
uint8_t event_mask[8];
uint8_t event_mask_page2[8];
@@ -251,25 +260,26 @@ struct btdev {
struct le_cig le_cig[CIG_SIZE];
uint8_t le_iso_path[2];
- /* Real time length of AL array */
- uint8_t le_al_len;
- /* Real time length of RL array */
- uint8_t le_rl_len;
- struct btdev_al le_al[AL_SIZE];
- struct btdev_rl le_rl[RL_SIZE];
uint8_t le_rl_enable;
- uint16_t le_rl_timeout;
struct pending_conn pending_conn[MAX_PENDING_CONN];
- uint8_t le_local_sk256[32];
-
uint16_t sync_train_interval;
uint32_t sync_train_timeout;
uint8_t sync_train_service_data;
uint16_t le_ext_adv_type;
+ ); /* reset_group */
+
+ /* Real time length of AL array */
+ uint8_t le_al_len;
+ /* Real time length of RL array */
+ uint8_t le_rl_len;
+ struct btdev_al le_al[AL_SIZE];
+ struct btdev_rl le_rl[RL_SIZE];
+ uint16_t le_rl_timeout;
+
struct queue *le_ext_adv;
struct queue *le_per_adv;
struct queue *le_big;
@@ -619,15 +629,52 @@ static void le_big_free(void *data)
free(big);
}
+static void btdev_init_param(struct btdev *btdev)
+{
+ unsigned int i;
+
+ btdev->page_scan_interval = 0x0800;
+ btdev->page_scan_window = 0x0012;
+ btdev->page_scan_type = 0x00;
+
+ btdev->sync_train_interval = 0x0080;
+ btdev->sync_train_timeout = 0x0002ee00;
+ btdev->sync_train_service_data = 0x00;
+
+ btdev->acl_mtu = 192;
+ btdev->acl_max_pkt = 1;
+
+ btdev->sco_mtu = 72;
+ btdev->sco_max_pkt = 1;
+
+ btdev->iso_mtu = 251;
+ btdev->iso_max_pkt = 1;
+
+ for (i = 0; i < ARRAY_SIZE(btdev->le_cig); ++i)
+ btdev->le_cig[i].params.cig_id = 0xff;
+
+ btdev->country_code = 0x00;
+}
+
static void btdev_reset(struct btdev *btdev)
{
/* FIXME: include here clearing of all states that should be
* cleared upon HCI_Reset
*/
- btdev->le_scan_enable = 0x00;
- btdev->le_adv_enable = 0x00;
- btdev->le_pa_enable = 0x00;
+ if (btdev->inquiry_id > 0) {
+ timeout_remove(btdev->inquiry_id);
+ btdev->inquiry_id = 0;
+ }
+
+ queue_remove_all(btdev->conns, NULL, NULL, conn_remove);
+ queue_remove_all(btdev->le_ext_adv, NULL, NULL, le_ext_adv_free);
+ queue_remove_all(btdev->le_per_adv, NULL, NULL, free);
+ queue_remove_all(btdev->le_big, NULL, NULL, le_big_free);
+
+ memset(&btdev->reset_group, 0, sizeof(btdev->reset_group));
+
+ btdev_init_param(btdev);
al_clear(btdev);
rl_clear(btdev);
@@ -635,10 +682,7 @@ static void btdev_reset(struct btdev *btdev)
btdev->le_al_len = AL_SIZE;
btdev->le_rl_len = RL_SIZE;
- queue_remove_all(btdev->conns, NULL, NULL, conn_remove);
- queue_remove_all(btdev->le_ext_adv, NULL, NULL, le_ext_adv_free);
- queue_remove_all(btdev->le_per_adv, NULL, NULL, free);
- queue_remove_all(btdev->le_big, NULL, NULL, le_big_free);
+ btdev->le_rl_timeout = 0x0384;
}
static int cmd_reset(struct btdev *dev, const void *data, uint8_t len)
@@ -8132,7 +8176,6 @@ struct btdev *btdev_create(enum btdev_type type, uint16_t id)
{
struct btdev *btdev;
int index;
- unsigned int i;
btdev = malloc(sizeof(*btdev));
if (!btdev)
@@ -8197,27 +8240,7 @@ struct btdev *btdev_create(enum btdev_type type, uint16_t id)
break;
}
- btdev->page_scan_interval = 0x0800;
- btdev->page_scan_window = 0x0012;
- btdev->page_scan_type = 0x00;
-
- btdev->sync_train_interval = 0x0080;
- btdev->sync_train_timeout = 0x0002ee00;
- btdev->sync_train_service_data = 0x00;
-
- btdev->acl_mtu = 192;
- btdev->acl_max_pkt = 1;
-
- btdev->sco_mtu = 72;
- btdev->sco_max_pkt = 1;
-
- btdev->iso_mtu = 251;
- btdev->iso_max_pkt = 1;
-
- for (i = 0; i < ARRAY_SIZE(btdev->le_cig); ++i)
- btdev->le_cig[i].params.cig_id = 0xff;
-
- btdev->country_code = 0x00;
+ btdev_init_param(btdev);
index = add_btdev(btdev);
if (index < 0) {
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 05/20] test-runner: enable path argument for --unix
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (3 preceding siblings ...)
2026-03-22 21:29 ` [PATCH BlueZ v3 04/20] emulator: btdev: clear more state on Reset Pauli Virtanen
@ 2026-03-22 21:29 ` Pauli Virtanen
2026-03-22 21:29 ` [PATCH BlueZ v3 06/20] test-runner: Add -o/--option option Pauli Virtanen
` (14 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:29 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Allow specifying the path for the controller socket to be used.
---
tools/test-runner.c | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/tools/test-runner.c b/tools/test-runner.c
index 48b7c1589..331cb6eb1 100644
--- a/tools/test-runner.c
+++ b/tools/test-runner.c
@@ -54,6 +54,7 @@ static bool start_monitor = false;
static bool qemu_host_cpu = false;
static int num_devs = 0;
static int num_emulator = 0;
+static const char *device_path = "/tmp/bt-server-bredr";
static const char *qemu_binary = NULL;
static const char *kernel_image = NULL;
static char *audio_server;
@@ -313,11 +314,10 @@ static void start_qemu(void)
argv[pos++] = (char *) cmdline;
for (i = 0; i < num_devs; i++) {
- const char *path = "/tmp/bt-server-bredr";
char *chrdev, *serdev;
- chrdev = alloca(48 + strlen(path));
- sprintf(chrdev, "socket,path=%s,id=bt%d", path, i);
+ chrdev = alloca(48 + strlen(device_path));
+ sprintf(chrdev, "socket,path=%s,id=bt%d", device_path, i);
serdev = alloca(48);
sprintf(serdev, "pci-serial,chardev=bt%d", i);
@@ -1198,7 +1198,7 @@ static void usage(void)
"\t-m, --monitor Start btmon\n"
"\t-l, --emulator[=num] Start btvirt\n"
"\t-A, --audio[=path] Start audio server\n"
- "\t-u, --unix [path] Provide serial device\n"
+ "\t-u, --unix[=path] Provide serial device\n"
"\t-U, --usb [qemu_args] Provide USB device\n"
"\t-q, --qemu <path> QEMU binary\n"
"\t-H, --qemu-host-cpu Use host CPU (requires KVM support)\n"
@@ -1211,7 +1211,7 @@ static const struct option main_options[] = {
{ "auto", no_argument, NULL, 'a' },
{ "dbus", no_argument, NULL, 'b' },
{ "dbus-session", no_argument, NULL, 's' },
- { "unix", no_argument, NULL, 'u' },
+ { "unix", optional_argument, NULL, 'u' },
{ "daemon", no_argument, NULL, 'd' },
{ "emulator", no_argument, NULL, 'l' },
{ "monitor", no_argument, NULL, 'm' },
@@ -1239,7 +1239,7 @@ int main(int argc, char *argv[])
for (;;) {
int opt;
- opt = getopt_long(argc, argv, "aubdsl::mq:Hk:A::U:vh",
+ opt = getopt_long(argc, argv, "au::bdsl::mq:Hk:A::U:vh",
main_options, NULL);
if (opt < 0)
break;
@@ -1250,6 +1250,8 @@ int main(int argc, char *argv[])
break;
case 'u':
num_devs = 1;
+ if (optarg)
+ device_path = optarg;
break;
case 'b':
start_dbus = true;
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 06/20] test-runner: Add -o/--option option
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (4 preceding siblings ...)
2026-03-22 21:29 ` [PATCH BlueZ v3 05/20] test-runner: enable path argument for --unix Pauli Virtanen
@ 2026-03-22 21:29 ` Pauli Virtanen
2026-03-22 21:29 ` [PATCH BlueZ v3 07/20] test-runner: allow source tree root for -k Pauli Virtanen
` (13 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:29 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Allow passing arbitrary arguments to QEMU.
---
tools/test-runner.c | 22 +++++++++++++++++++---
1 file changed, 19 insertions(+), 3 deletions(-)
diff --git a/tools/test-runner.c b/tools/test-runner.c
index 331cb6eb1..3de3a9d74 100644
--- a/tools/test-runner.c
+++ b/tools/test-runner.c
@@ -41,6 +41,7 @@
#endif
#define CMDLINE_MAX (2048 * 10)
+#define EXTRA_OPT_MAX 64
static const char *own_binary;
static char **test_argv;
@@ -59,6 +60,8 @@ static const char *qemu_binary = NULL;
static const char *kernel_image = NULL;
static char *audio_server;
static char *usb_dev;
+static char *extra_opts[EXTRA_OPT_MAX];
+static int num_extra_opts;
static const char *qemu_table[] = {
"qemu-system-x86_64",
@@ -291,7 +294,8 @@ static void start_qemu(void)
argv = alloca(sizeof(qemu_argv) +
(sizeof(char *) * (6 + (num_devs * 4))) +
- (sizeof(char *) * (usb_dev ? 4 : 0)));
+ (sizeof(char *) * (usb_dev ? 4 : 0)) +
+ (sizeof(char *) * num_extra_opts));
memcpy(argv, qemu_argv, sizeof(qemu_argv));
pos = (sizeof(qemu_argv) / sizeof(char *)) - 1;
@@ -335,6 +339,9 @@ static void start_qemu(void)
argv[pos++] = usb_dev;
}
+ for (i = 0; i < num_extra_opts; ++i)
+ argv[pos++] = extra_opts[i];
+
argv[pos] = NULL;
execve(argv[0], argv, qemu_envp);
@@ -1199,10 +1206,11 @@ static void usage(void)
"\t-l, --emulator[=num] Start btvirt\n"
"\t-A, --audio[=path] Start audio server\n"
"\t-u, --unix[=path] Provide serial device\n"
- "\t-U, --usb [qemu_args] Provide USB device\n"
+ "\t-U, --usb <qemu_args> Provide USB device\n"
"\t-q, --qemu <path> QEMU binary\n"
"\t-H, --qemu-host-cpu Use host CPU (requires KVM support)\n"
"\t-k, --kernel <image> Kernel image (bzImage)\n"
+ "\t-o, --option <opt> Additional argument passed to QEMU\n"
"\t-h, --help Show help options\n");
}
@@ -1220,6 +1228,7 @@ static const struct option main_options[] = {
{ "kernel", required_argument, NULL, 'k' },
{ "audio", optional_argument, NULL, 'A' },
{ "usb", required_argument, NULL, 'U' },
+ { "option", required_argument, NULL, 'o' },
{ "version", no_argument, NULL, 'v' },
{ "help", no_argument, NULL, 'h' },
{ }
@@ -1239,7 +1248,7 @@ int main(int argc, char *argv[])
for (;;) {
int opt;
- opt = getopt_long(argc, argv, "au::bdsl::mq:Hk:A::U:vh",
+ opt = getopt_long(argc, argv, "au::bdsl::mq:Hk:A::U:o:vh",
main_options, NULL);
if (opt < 0)
break;
@@ -1284,6 +1293,13 @@ int main(int argc, char *argv[])
case 'U':
usb_dev = optarg;
break;
+ case 'o':
+ if (num_extra_opts >= EXTRA_OPT_MAX) {
+ fprintf(stderr, "Too many -o\n");
+ return EXIT_FAILURE;
+ }
+ extra_opts[num_extra_opts++] = optarg;
+ break;
case 'v':
printf("%s\n", VERSION);
return EXIT_SUCCESS;
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 07/20] test-runner: allow source tree root for -k
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (5 preceding siblings ...)
2026-03-22 21:29 ` [PATCH BlueZ v3 06/20] test-runner: Add -o/--option option Pauli Virtanen
@ 2026-03-22 21:29 ` Pauli Virtanen
2026-03-22 21:29 ` [PATCH BlueZ v3 08/20] doc: enable CONFIG_VIRTIO_CONSOLE in tester config Pauli Virtanen
` (12 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:29 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Allow passing source tree root for -k option, look up kernel below it.
---
tools/test-runner.c | 42 ++++++++++++++++++++++++++++--------------
1 file changed, 28 insertions(+), 14 deletions(-)
diff --git a/tools/test-runner.c b/tools/test-runner.c
index 3de3a9d74..b3e0b0cfe 100644
--- a/tools/test-runner.c
+++ b/tools/test-runner.c
@@ -93,18 +93,31 @@ static const char *kernel_table[] = {
NULL
};
-static const char *find_kernel(void)
+static bool find_kernel(const char *root, char path[PATH_MAX])
{
+ struct stat st;
int i;
- for (i = 0; kernel_table[i]; i++) {
- struct stat st;
-
- if (!stat(kernel_table[i], &st))
- return kernel_table[i];
+ if (root) {
+ snprintf(path, PATH_MAX, "%s", root);
+ if (stat(path, &st))
+ return false;
+ if (!(st.st_mode & S_IFDIR))
+ return true;
}
- return NULL;
+ for (i = 0; kernel_table[i]; i++) {
+ if (root)
+ snprintf(path, PATH_MAX, "%s/%s", root,
+ kernel_table[i]);
+ else
+ snprintf(path, PATH_MAX, "%s",
+ kernel_table[i]);
+ if (!stat(path, &st))
+ return true;
+ }
+
+ return false;
}
static const struct {
@@ -1209,7 +1222,7 @@ static void usage(void)
"\t-U, --usb <qemu_args> Provide USB device\n"
"\t-q, --qemu <path> QEMU binary\n"
"\t-H, --qemu-host-cpu Use host CPU (requires KVM support)\n"
- "\t-k, --kernel <image> Kernel image (bzImage)\n"
+ "\t-k, --kernel <image> Kernel bzImage or source tree path\n"
"\t-o, --option <opt> Additional argument passed to QEMU\n"
"\t-h, --help Show help options\n");
}
@@ -1236,6 +1249,8 @@ static const struct option main_options[] = {
int main(int argc, char *argv[])
{
+ char kernel_path[PATH_MAX];
+
if (getpid() == 1 && getppid() == 0) {
prepare_sandbox();
run_tests();
@@ -1335,14 +1350,13 @@ int main(int argc, char *argv[])
}
}
- if (!kernel_image) {
- kernel_image = find_kernel();
- if (!kernel_image) {
- fprintf(stderr, "No default kernel image found\n");
- return EXIT_FAILURE;
- }
+ if (!find_kernel(kernel_image, kernel_path)) {
+ fprintf(stderr, "No kernel image found\n");
+ return EXIT_FAILURE;
}
+ kernel_image = kernel_path;
+
printf("Using QEMU binary %s\n", qemu_binary);
printf("Using kernel image %s\n", kernel_image);
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 08/20] doc: enable CONFIG_VIRTIO_CONSOLE in tester config
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (6 preceding siblings ...)
2026-03-22 21:29 ` [PATCH BlueZ v3 07/20] test-runner: allow source tree root for -k Pauli Virtanen
@ 2026-03-22 21:29 ` Pauli Virtanen
2026-03-22 21:29 ` [PATCH BlueZ v3 09/20] test-runner: use virtio-serial for implementing -u device forwarding Pauli Virtanen
` (11 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:29 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Enable kernel option that allows using -device virtserialport in qemu.
This is easier to make work reliably than pci-serial channel.
---
doc/ci.config | 1 +
doc/test-runner.rst | 1 +
doc/tester.config | 1 +
3 files changed, 3 insertions(+)
diff --git a/doc/ci.config b/doc/ci.config
index 31e49ba96..a48c1af9d 100644
--- a/doc/ci.config
+++ b/doc/ci.config
@@ -6,6 +6,7 @@
CONFIG_VIRTIO=y
CONFIG_VIRTIO_PCI=y
+CONFIG_VIRTIO_CONSOLE=y
CONFIG_NET=y
CONFIG_INET=y
diff --git a/doc/test-runner.rst b/doc/test-runner.rst
index 64715e2e7..d030787a4 100644
--- a/doc/test-runner.rst
+++ b/doc/test-runner.rst
@@ -45,6 +45,7 @@ option (like the Bluetooth subsystem) can be enabled on top of this.
CONFIG_VIRTIO=y
CONFIG_VIRTIO_PCI=y
+ CONFIG_VIRTIO_CONSOLE=y
CONFIG_NET=y
CONFIG_INET=y
diff --git a/doc/tester.config b/doc/tester.config
index 4ee306405..015e7cc1a 100644
--- a/doc/tester.config
+++ b/doc/tester.config
@@ -1,6 +1,7 @@
CONFIG_PCI=y
CONFIG_VIRTIO=y
CONFIG_VIRTIO_PCI=y
+CONFIG_VIRTIO_CONSOLE=y
CONFIG_NET=y
CONFIG_INET=y
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 09/20] test-runner: use virtio-serial for implementing -u device forwarding
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (7 preceding siblings ...)
2026-03-22 21:29 ` [PATCH BlueZ v3 08/20] doc: enable CONFIG_VIRTIO_CONSOLE in tester config Pauli Virtanen
@ 2026-03-22 21:29 ` Pauli Virtanen
2026-03-24 20:24 ` Luiz Augusto von Dentz
2026-03-22 21:29 ` [PATCH BlueZ v3 10/20] doc: enable KVM paravirtualization & clock support in tester kernel config Pauli Virtanen
` (10 subsequent siblings)
19 siblings, 1 reply; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:29 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Using pci-serial to forward eg. btvirt sockets is unreliable, as qemu or
kernel seems to be sometimes dropping part of the sent data or insert
spurious \0 bytes, leading to sporadic errors like:
kernel: Bluetooth: hci0: command 0x0c52 tx timeout
kernel: Bluetooth: hci0: Opcode 0x0c52 failed: -110
btvirt: packet error, unknown type: 0
This appears to occur most often when host system is under load, e.g.
due to multiple test-runners running at the same time. The problem is
not specific to btvirt, but seems to be in the qemu serial device layer
vs. kernel interaction.
Change test-runner to use virtserialport to forward the btvirt
connection inside the VM, as virtio-serial doesn't appear to have these
problems.
Since it's not a TTY device, we have to do vport <-> tty-with-hci-ldisc
forwarding of the data in test-runner, so this becomes a bit more
involved.
---
Makefile.tools | 2 +
configure.ac | 9 ++
tools/test-runner.c | 300 +++++++++++++++++++++++++++++++++-----------
3 files changed, 241 insertions(+), 70 deletions(-)
diff --git a/Makefile.tools b/Makefile.tools
index f6de2e685..6e30a535f 100644
--- a/Makefile.tools
+++ b/Makefile.tools
@@ -321,6 +321,8 @@ tools_btgatt_server_SOURCES = tools/btgatt-server.c src/uuid-helper.c
tools_btgatt_server_LDADD = src/libshared-mainloop.la \
lib/libbluetooth-internal.la
+tools_test_runner_LDADD = $(OPENPTY_LIBS)
+
tools_rctest_LDADD = lib/libbluetooth-internal.la
tools_l2test_LDADD = lib/libbluetooth-internal.la
diff --git a/configure.ac b/configure.ac
index 52de7d665..3bc1f5c44 100644
--- a/configure.ac
+++ b/configure.ac
@@ -251,6 +251,15 @@ AC_ARG_ENABLE(tools, AS_HELP_STRING([--disable-tools],
[disable Bluetooth tools]), [enable_tools=${enableval}])
AM_CONDITIONAL(TOOLS, test "${enable_tools}" != "no")
+openpty_libs=
+if (test "${enable_tools}" != "no"); then
+ AC_CHECK_FUNCS([openpty], [openpty_libs=],
+ [AC_CHECK_LIB([util], [openpty], [openpty_libs=-lutil],
+ [AC_CHECK_LIB([bsd], [openpty], [openpty_libs=-lbsd],
+ [AC_MSG_ERROR([openpty not found])])])])
+fi
+AC_SUBST(OPENPTY_LIBS, [${openpty_libs}])
+
AC_ARG_ENABLE(monitor, AS_HELP_STRING([--disable-monitor],
[disable Bluetooth monitor]), [enable_monitor=${enableval}])
AM_CONDITIONAL(MONITOR, test "${enable_monitor}" != "no")
diff --git a/tools/test-runner.c b/tools/test-runner.c
index b3e0b0cfe..576313b79 100644
--- a/tools/test-runner.c
+++ b/tools/test-runner.c
@@ -24,6 +24,9 @@
#include <getopt.h>
#include <poll.h>
#include <limits.h>
+#include <dirent.h>
+#include <pty.h>
+#include <stdint.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <sys/types.h>
@@ -306,7 +309,7 @@ static void start_qemu(void)
testargs);
argv = alloca(sizeof(qemu_argv) +
- (sizeof(char *) * (6 + (num_devs * 4))) +
+ (sizeof(char *) * (8 + (num_devs * 4))) +
(sizeof(char *) * (usb_dev ? 4 : 0)) +
(sizeof(char *) * num_extra_opts));
memcpy(argv, qemu_argv, sizeof(qemu_argv));
@@ -330,14 +333,17 @@ static void start_qemu(void)
argv[pos++] = "-append";
argv[pos++] = (char *) cmdline;
+ argv[pos++] = "-device";
+ argv[pos++] = "virtio-serial";
+
for (i = 0; i < num_devs; i++) {
char *chrdev, *serdev;
chrdev = alloca(48 + strlen(device_path));
sprintf(chrdev, "socket,path=%s,id=bt%d", device_path, i);
- serdev = alloca(48);
- sprintf(serdev, "pci-serial,chardev=bt%d", i);
+ serdev = alloca(64);
+ sprintf(serdev, "virtserialport,chardev=bt%d,name=bt.%d", i, i);
argv[pos++] = "-chardev";
argv[pos++] = chrdev;
@@ -360,65 +366,12 @@ static void start_qemu(void)
execve(argv[0], argv, qemu_envp);
}
-static int open_serial(const char *path)
-{
- struct termios ti;
- int fd, saved_ldisc, ldisc = N_HCI;
-
- fd = open(path, O_RDWR | O_NOCTTY);
- if (fd < 0) {
- perror("Failed to open serial port");
- return -1;
- }
-
- if (tcflush(fd, TCIOFLUSH) < 0) {
- perror("Failed to flush serial port");
- close(fd);
- return -1;
- }
-
- if (ioctl(fd, TIOCGETD, &saved_ldisc) < 0) {
- perror("Failed get serial line discipline");
- close(fd);
- return -1;
- }
-
- /* Switch TTY to raw mode */
- memset(&ti, 0, sizeof(ti));
- cfmakeraw(&ti);
-
- ti.c_cflag |= (B115200 | CLOCAL | CREAD);
-
- /* Set flow control */
- ti.c_cflag |= CRTSCTS;
-
- if (tcsetattr(fd, TCSANOW, &ti) < 0) {
- perror("Failed to set serial port settings");
- close(fd);
- return -1;
- }
-
- if (ioctl(fd, TIOCSETD, &ldisc) < 0) {
- perror("Failed set serial line discipline");
- close(fd);
- return -1;
- }
-
- printf("Switched line discipline from %d to %d\n", saved_ldisc, ldisc);
-
- return fd;
-}
-
-static int attach_proto(const char *path, unsigned int proto,
+static int attach_proto(int fd, unsigned int proto,
unsigned int mandatory_flags,
unsigned int optional_flags)
{
unsigned int flags = mandatory_flags | optional_flags;
- int fd, dev_id;
-
- fd = open_serial(path);
- if (fd < 0)
- return -1;
+ int dev_id;
if (ioctl(fd, HCIUARTSETFLAGS, flags) < 0) {
if (errno == EINVAL) {
@@ -895,13 +848,222 @@ static int start_audio_server(pid_t pids[2])
return 0;
}
+static bool find_attach_dev(char path[PATH_MAX])
+{
+ const char *vport_path = "/sys/class/virtio-ports";
+ struct dirent *entry;
+ DIR *dir;
+
+ dir = opendir(vport_path);
+ if (!dir)
+ return false;
+
+ while ((entry = readdir(dir)) != NULL) {
+ FILE *f;
+ char buf[64];
+ size_t size;
+
+ snprintf(path, PATH_MAX, "%s/%s/name", vport_path,
+ entry->d_name);
+ f = fopen(path, "r");
+ if (!f)
+ continue;
+
+ size = fread(buf, 1, sizeof(buf) - 1, f);
+ buf[size] = 0;
+
+ fclose(f);
+
+ if (strncmp(buf, "bt.", 3) == 0) {
+ snprintf(path, PATH_MAX, "/dev/%s", entry->d_name);
+ closedir(dir);
+ return true;
+ }
+ }
+
+ closedir(dir);
+ return false;
+}
+
+static void copy_fd_bidi(int src, int dst)
+{
+ fd_set rfds, wfds;
+ int fd[2] = { src, dst };
+ uint8_t buf[2][4096];
+ size_t size[2] = { 0, 0 };
+ size_t pos[2] = { 0, 0 };
+ int i, ret;
+
+ /* Simple copying of data src <-> dst to both directions */
+
+ for (i = 0; i < 2; ++i) {
+ int flags = fcntl(fd[i], F_GETFL);
+
+ if (fcntl(fd[i], F_SETFL, flags | O_NONBLOCK) < 0) {
+ perror("fcntl");
+ goto error;
+ }
+ }
+
+ while (1) {
+ FD_ZERO(&rfds);
+ FD_ZERO(&wfds);
+
+ for (i = 0; i < 2; ++i) {
+ if (size[i])
+ FD_SET(fd[i], &wfds);
+ else
+ FD_SET(fd[1 - i], &rfds);
+ }
+
+ ret = select(FD_SETSIZE, &rfds, &wfds, NULL, NULL);
+ if (ret < 0) {
+ if (errno == EINTR)
+ continue;
+ perror("select");
+ goto error;
+ }
+
+ for (i = 0; i < 2; ++i) {
+ ssize_t s;
+
+ if (!size[i] && FD_ISSET(fd[1 - i], &rfds)) {
+ s = read(fd[1 - i], buf[i], sizeof(buf[i]));
+ if (s >= 0) {
+ size[i] = s;
+ pos[i] = 0;
+ } else if (errno == EINTR) {
+ /* ok */
+ } else {
+ perror("read");
+ goto error;
+ }
+
+ }
+
+ if (size[i]) {
+ s = write(fd[i], buf[i] + pos[i], size[i]);
+ if (s >= 0) {
+ size[i] -= s;
+ pos[i] += s;
+ } else if (errno == EINTR || errno == EAGAIN
+ || errno == EWOULDBLOCK) {
+ /* ok */
+ } else {
+ perror("write");
+ goto error;
+ }
+ }
+ }
+ }
+ return;
+
+error:
+ fprintf(stderr, "Bluetooth controller forward terminated with error\n");
+ exit(1);
+}
+
+static int start_controller_forward(const char *path, pid_t *controller_pid)
+{
+ struct termios ti;
+ pid_t pid;
+ int src = -1, dst = -1, fd = -1;
+ int ret, saved_ldisc, ldisc = N_HCI;
+
+ /* virtio-serial ports cannot be used for HCI line disciple, so
+ * openpty() serial device and forward data to/from it.
+ */
+
+ src = open(path, O_RDWR);
+ if (src < 0)
+ goto error;
+
+ /* Raw mode TTY */
+ memset(&ti, 0, sizeof(ti));
+ cfmakeraw(&ti);
+ ti.c_cflag |= B115200 | CLOCAL | CREAD;
+
+ /* With flow control */
+ ti.c_cflag |= CRTSCTS;
+
+ ret = openpty(&dst, &fd, NULL, &ti, NULL);
+ if (ret < 0)
+ goto error;
+
+ if (ioctl(fd, TIOCGETD, &saved_ldisc) < 0) {
+ perror("Failed get serial line discipline");
+ goto error;
+ }
+
+ if (ioctl(fd, TIOCSETD, &ldisc) < 0) {
+ perror("Failed set serial line discipline");
+ goto error;
+ }
+
+ printf("Switched line discipline from %d to %d\n", saved_ldisc, ldisc);
+
+ pid = fork();
+ if (pid < 0) {
+ perror("Failed to fork new process");
+ goto error;
+ } else if (pid == 0) {
+ close(fd);
+ copy_fd_bidi(src, dst);
+ exit(0);
+ }
+
+ *controller_pid = pid;
+
+ close(src);
+ close(dst);
+ return fd;
+
+error:
+ if (src >= 0)
+ close(src);
+ if (dst >= 0)
+ close(dst);
+ if (fd >= 0)
+ close(fd);
+ return -1;
+}
+
+static int attach_controller(pid_t *controller_pid)
+{
+ unsigned int basic_flags, extra_flags;
+ char path[PATH_MAX];
+ int fd;
+
+ *controller_pid = -1;
+
+ if (!find_attach_dev(path)) {
+ printf("Failed to find Bluetooth controller virtio\n");
+ return -1;
+ }
+
+ printf("Forwarding Bluetooth controller from %s\n", path);
+
+ fd = start_controller_forward(path, controller_pid);
+ if (fd < 0) {
+ printf("Failed to forward Bluetooth controller\n");
+ return -1;
+ }
+
+ basic_flags = (1 << HCI_UART_RESET_ON_INIT);
+ extra_flags = (1 << HCI_UART_VND_DETECT);
+
+ printf("Attaching Bluetooth controller\n");
+
+ return attach_proto(fd, HCI_UART_H4, basic_flags, extra_flags);
+}
+
static void run_command(char *cmdname, char *home)
{
char *argv[9], *envp[3];
int pos = 0, idx = 0;
int serial_fd;
pid_t pid, dbus_pid, daemon_pid, monitor_pid, emulator_pid,
- dbus_session_pid, audio_pid[2];
+ dbus_session_pid, audio_pid[2], controller_pid;
int i;
if (!home) {
@@ -910,18 +1072,11 @@ static void run_command(char *cmdname, char *home)
}
if (num_devs) {
- const char *node = "/dev/ttyS1";
- unsigned int basic_flags, extra_flags;
-
- printf("Attaching BR/EDR controller to %s\n", node);
-
- basic_flags = (1 << HCI_UART_RESET_ON_INIT);
- extra_flags = (1 << HCI_UART_VND_DETECT);
-
- serial_fd = attach_proto(node, HCI_UART_H4, basic_flags,
- extra_flags);
- } else
+ serial_fd = attach_controller(&controller_pid);
+ } else {
serial_fd = -1;
+ controller_pid = -1;
+ }
if (start_dbus) {
create_dbus_system_conf();
@@ -1063,6 +1218,11 @@ start_next:
monitor_pid = -1;
}
+ if (corpse == controller_pid) {
+ printf("Controller terminated\n");
+ controller_pid = -1;
+ }
+
for (i = 0; i < 2; ++i) {
if (corpse == audio_pid[i]) {
printf("Audio server %d terminated\n", i);
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* Re: [PATCH BlueZ v3 09/20] test-runner: use virtio-serial for implementing -u device forwarding
2026-03-22 21:29 ` [PATCH BlueZ v3 09/20] test-runner: use virtio-serial for implementing -u device forwarding Pauli Virtanen
@ 2026-03-24 20:24 ` Luiz Augusto von Dentz
2026-03-24 21:00 ` Pauli Virtanen
0 siblings, 1 reply; 26+ messages in thread
From: Luiz Augusto von Dentz @ 2026-03-24 20:24 UTC (permalink / raw)
To: Pauli Virtanen; +Cc: linux-bluetooth
Hi Pauli,
On Sun, Mar 22, 2026 at 5:30 PM Pauli Virtanen <pav@iki.fi> wrote:
>
> Using pci-serial to forward eg. btvirt sockets is unreliable, as qemu or
> kernel seems to be sometimes dropping part of the sent data or insert
> spurious \0 bytes, leading to sporadic errors like:
>
> kernel: Bluetooth: hci0: command 0x0c52 tx timeout
> kernel: Bluetooth: hci0: Opcode 0x0c52 failed: -110
> btvirt: packet error, unknown type: 0
>
> This appears to occur most often when host system is under load, e.g.
> due to multiple test-runners running at the same time. The problem is
> not specific to btvirt, but seems to be in the qemu serial device layer
> vs. kernel interaction.
>
> Change test-runner to use virtserialport to forward the btvirt
> connection inside the VM, as virtio-serial doesn't appear to have these
> problems.
>
> Since it's not a TTY device, we have to do vport <-> tty-with-hci-ldisc
> forwarding of the data in test-runner, so this becomes a bit more
> involved.
Lets try to sort this out first before we go into the functional
framework. Does the vport not working as tty perhaps require a kernel
driver or something to enable direct operation?
> ---
> Makefile.tools | 2 +
> configure.ac | 9 ++
> tools/test-runner.c | 300 +++++++++++++++++++++++++++++++++-----------
> 3 files changed, 241 insertions(+), 70 deletions(-)
>
> diff --git a/Makefile.tools b/Makefile.tools
> index f6de2e685..6e30a535f 100644
> --- a/Makefile.tools
> +++ b/Makefile.tools
> @@ -321,6 +321,8 @@ tools_btgatt_server_SOURCES = tools/btgatt-server.c src/uuid-helper.c
> tools_btgatt_server_LDADD = src/libshared-mainloop.la \
> lib/libbluetooth-internal.la
>
> +tools_test_runner_LDADD = $(OPENPTY_LIBS)
> +
> tools_rctest_LDADD = lib/libbluetooth-internal.la
>
> tools_l2test_LDADD = lib/libbluetooth-internal.la
> diff --git a/configure.ac b/configure.ac
> index 52de7d665..3bc1f5c44 100644
> --- a/configure.ac
> +++ b/configure.ac
> @@ -251,6 +251,15 @@ AC_ARG_ENABLE(tools, AS_HELP_STRING([--disable-tools],
> [disable Bluetooth tools]), [enable_tools=${enableval}])
> AM_CONDITIONAL(TOOLS, test "${enable_tools}" != "no")
>
> +openpty_libs=
> +if (test "${enable_tools}" != "no"); then
> + AC_CHECK_FUNCS([openpty], [openpty_libs=],
> + [AC_CHECK_LIB([util], [openpty], [openpty_libs=-lutil],
> + [AC_CHECK_LIB([bsd], [openpty], [openpty_libs=-lbsd],
> + [AC_MSG_ERROR([openpty not found])])])])
> +fi
> +AC_SUBST(OPENPTY_LIBS, [${openpty_libs}])
> +
> AC_ARG_ENABLE(monitor, AS_HELP_STRING([--disable-monitor],
> [disable Bluetooth monitor]), [enable_monitor=${enableval}])
> AM_CONDITIONAL(MONITOR, test "${enable_monitor}" != "no")
> diff --git a/tools/test-runner.c b/tools/test-runner.c
> index b3e0b0cfe..576313b79 100644
> --- a/tools/test-runner.c
> +++ b/tools/test-runner.c
> @@ -24,6 +24,9 @@
> #include <getopt.h>
> #include <poll.h>
> #include <limits.h>
> +#include <dirent.h>
> +#include <pty.h>
> +#include <stdint.h>
> #include <sys/wait.h>
> #include <sys/stat.h>
> #include <sys/types.h>
> @@ -306,7 +309,7 @@ static void start_qemu(void)
> testargs);
>
> argv = alloca(sizeof(qemu_argv) +
> - (sizeof(char *) * (6 + (num_devs * 4))) +
> + (sizeof(char *) * (8 + (num_devs * 4))) +
> (sizeof(char *) * (usb_dev ? 4 : 0)) +
> (sizeof(char *) * num_extra_opts));
> memcpy(argv, qemu_argv, sizeof(qemu_argv));
> @@ -330,14 +333,17 @@ static void start_qemu(void)
> argv[pos++] = "-append";
> argv[pos++] = (char *) cmdline;
>
> + argv[pos++] = "-device";
> + argv[pos++] = "virtio-serial";
> +
> for (i = 0; i < num_devs; i++) {
> char *chrdev, *serdev;
>
> chrdev = alloca(48 + strlen(device_path));
> sprintf(chrdev, "socket,path=%s,id=bt%d", device_path, i);
>
> - serdev = alloca(48);
> - sprintf(serdev, "pci-serial,chardev=bt%d", i);
> + serdev = alloca(64);
> + sprintf(serdev, "virtserialport,chardev=bt%d,name=bt.%d", i, i);
>
> argv[pos++] = "-chardev";
> argv[pos++] = chrdev;
> @@ -360,65 +366,12 @@ static void start_qemu(void)
> execve(argv[0], argv, qemu_envp);
> }
>
> -static int open_serial(const char *path)
> -{
> - struct termios ti;
> - int fd, saved_ldisc, ldisc = N_HCI;
> -
> - fd = open(path, O_RDWR | O_NOCTTY);
> - if (fd < 0) {
> - perror("Failed to open serial port");
> - return -1;
> - }
> -
> - if (tcflush(fd, TCIOFLUSH) < 0) {
> - perror("Failed to flush serial port");
> - close(fd);
> - return -1;
> - }
> -
> - if (ioctl(fd, TIOCGETD, &saved_ldisc) < 0) {
> - perror("Failed get serial line discipline");
> - close(fd);
> - return -1;
> - }
> -
> - /* Switch TTY to raw mode */
> - memset(&ti, 0, sizeof(ti));
> - cfmakeraw(&ti);
> -
> - ti.c_cflag |= (B115200 | CLOCAL | CREAD);
> -
> - /* Set flow control */
> - ti.c_cflag |= CRTSCTS;
> -
> - if (tcsetattr(fd, TCSANOW, &ti) < 0) {
> - perror("Failed to set serial port settings");
> - close(fd);
> - return -1;
> - }
> -
> - if (ioctl(fd, TIOCSETD, &ldisc) < 0) {
> - perror("Failed set serial line discipline");
> - close(fd);
> - return -1;
> - }
> -
> - printf("Switched line discipline from %d to %d\n", saved_ldisc, ldisc);
> -
> - return fd;
> -}
> -
> -static int attach_proto(const char *path, unsigned int proto,
> +static int attach_proto(int fd, unsigned int proto,
> unsigned int mandatory_flags,
> unsigned int optional_flags)
> {
> unsigned int flags = mandatory_flags | optional_flags;
> - int fd, dev_id;
> -
> - fd = open_serial(path);
> - if (fd < 0)
> - return -1;
> + int dev_id;
>
> if (ioctl(fd, HCIUARTSETFLAGS, flags) < 0) {
> if (errno == EINVAL) {
> @@ -895,13 +848,222 @@ static int start_audio_server(pid_t pids[2])
> return 0;
> }
>
> +static bool find_attach_dev(char path[PATH_MAX])
> +{
> + const char *vport_path = "/sys/class/virtio-ports";
> + struct dirent *entry;
> + DIR *dir;
> +
> + dir = opendir(vport_path);
> + if (!dir)
> + return false;
> +
> + while ((entry = readdir(dir)) != NULL) {
> + FILE *f;
> + char buf[64];
> + size_t size;
> +
> + snprintf(path, PATH_MAX, "%s/%s/name", vport_path,
> + entry->d_name);
> + f = fopen(path, "r");
> + if (!f)
> + continue;
> +
> + size = fread(buf, 1, sizeof(buf) - 1, f);
> + buf[size] = 0;
> +
> + fclose(f);
> +
> + if (strncmp(buf, "bt.", 3) == 0) {
> + snprintf(path, PATH_MAX, "/dev/%s", entry->d_name);
> + closedir(dir);
> + return true;
> + }
> + }
> +
> + closedir(dir);
> + return false;
> +}
> +
> +static void copy_fd_bidi(int src, int dst)
> +{
> + fd_set rfds, wfds;
> + int fd[2] = { src, dst };
> + uint8_t buf[2][4096];
> + size_t size[2] = { 0, 0 };
> + size_t pos[2] = { 0, 0 };
> + int i, ret;
> +
> + /* Simple copying of data src <-> dst to both directions */
> +
> + for (i = 0; i < 2; ++i) {
> + int flags = fcntl(fd[i], F_GETFL);
> +
> + if (fcntl(fd[i], F_SETFL, flags | O_NONBLOCK) < 0) {
> + perror("fcntl");
> + goto error;
> + }
> + }
> +
> + while (1) {
> + FD_ZERO(&rfds);
> + FD_ZERO(&wfds);
> +
> + for (i = 0; i < 2; ++i) {
> + if (size[i])
> + FD_SET(fd[i], &wfds);
> + else
> + FD_SET(fd[1 - i], &rfds);
> + }
> +
> + ret = select(FD_SETSIZE, &rfds, &wfds, NULL, NULL);
> + if (ret < 0) {
> + if (errno == EINTR)
> + continue;
> + perror("select");
> + goto error;
> + }
> +
> + for (i = 0; i < 2; ++i) {
> + ssize_t s;
> +
> + if (!size[i] && FD_ISSET(fd[1 - i], &rfds)) {
> + s = read(fd[1 - i], buf[i], sizeof(buf[i]));
> + if (s >= 0) {
> + size[i] = s;
> + pos[i] = 0;
> + } else if (errno == EINTR) {
> + /* ok */
> + } else {
> + perror("read");
> + goto error;
> + }
> +
> + }
> +
> + if (size[i]) {
> + s = write(fd[i], buf[i] + pos[i], size[i]);
> + if (s >= 0) {
> + size[i] -= s;
> + pos[i] += s;
> + } else if (errno == EINTR || errno == EAGAIN
> + || errno == EWOULDBLOCK) {
> + /* ok */
> + } else {
> + perror("write");
> + goto error;
> + }
> + }
> + }
> + }
It doesn't look that great if you ask me. I think this requires us to
copy significantly more data than before, likely resulting in worse
performance, which could be detrimental in resource-limited
environments like GitHub CI/CD.
> + return;
> +
> +error:
> + fprintf(stderr, "Bluetooth controller forward terminated with error\n");
> + exit(1);
> +}
> +
> +static int start_controller_forward(const char *path, pid_t *controller_pid)
> +{
> + struct termios ti;
> + pid_t pid;
> + int src = -1, dst = -1, fd = -1;
> + int ret, saved_ldisc, ldisc = N_HCI;
> +
> + /* virtio-serial ports cannot be used for HCI line disciple, so
> + * openpty() serial device and forward data to/from it.
> + */
> +
> + src = open(path, O_RDWR);
> + if (src < 0)
> + goto error;
> +
> + /* Raw mode TTY */
> + memset(&ti, 0, sizeof(ti));
> + cfmakeraw(&ti);
> + ti.c_cflag |= B115200 | CLOCAL | CREAD;
> +
> + /* With flow control */
> + ti.c_cflag |= CRTSCTS;
> +
> + ret = openpty(&dst, &fd, NULL, &ti, NULL);
> + if (ret < 0)
> + goto error;
> +
> + if (ioctl(fd, TIOCGETD, &saved_ldisc) < 0) {
> + perror("Failed get serial line discipline");
> + goto error;
> + }
> +
> + if (ioctl(fd, TIOCSETD, &ldisc) < 0) {
> + perror("Failed set serial line discipline");
> + goto error;
> + }
> +
> + printf("Switched line discipline from %d to %d\n", saved_ldisc, ldisc);
> +
> + pid = fork();
> + if (pid < 0) {
> + perror("Failed to fork new process");
> + goto error;
> + } else if (pid == 0) {
> + close(fd);
> + copy_fd_bidi(src, dst);
> + exit(0);
> + }
> +
> + *controller_pid = pid;
> +
> + close(src);
> + close(dst);
> + return fd;
> +
> +error:
> + if (src >= 0)
> + close(src);
> + if (dst >= 0)
> + close(dst);
> + if (fd >= 0)
> + close(fd);
> + return -1;
> +}
> +
> +static int attach_controller(pid_t *controller_pid)
> +{
> + unsigned int basic_flags, extra_flags;
> + char path[PATH_MAX];
> + int fd;
> +
> + *controller_pid = -1;
> +
> + if (!find_attach_dev(path)) {
> + printf("Failed to find Bluetooth controller virtio\n");
> + return -1;
> + }
> +
> + printf("Forwarding Bluetooth controller from %s\n", path);
> +
> + fd = start_controller_forward(path, controller_pid);
> + if (fd < 0) {
> + printf("Failed to forward Bluetooth controller\n");
> + return -1;
> + }
> +
> + basic_flags = (1 << HCI_UART_RESET_ON_INIT);
> + extra_flags = (1 << HCI_UART_VND_DETECT);
> +
> + printf("Attaching Bluetooth controller\n");
> +
> + return attach_proto(fd, HCI_UART_H4, basic_flags, extra_flags);
> +}
> +
> static void run_command(char *cmdname, char *home)
> {
> char *argv[9], *envp[3];
> int pos = 0, idx = 0;
> int serial_fd;
> pid_t pid, dbus_pid, daemon_pid, monitor_pid, emulator_pid,
> - dbus_session_pid, audio_pid[2];
> + dbus_session_pid, audio_pid[2], controller_pid;
> int i;
>
> if (!home) {
> @@ -910,18 +1072,11 @@ static void run_command(char *cmdname, char *home)
> }
>
> if (num_devs) {
> - const char *node = "/dev/ttyS1";
> - unsigned int basic_flags, extra_flags;
> -
> - printf("Attaching BR/EDR controller to %s\n", node);
> -
> - basic_flags = (1 << HCI_UART_RESET_ON_INIT);
> - extra_flags = (1 << HCI_UART_VND_DETECT);
> -
> - serial_fd = attach_proto(node, HCI_UART_H4, basic_flags,
> - extra_flags);
> - } else
> + serial_fd = attach_controller(&controller_pid);
> + } else {
> serial_fd = -1;
> + controller_pid = -1;
> + }
>
> if (start_dbus) {
> create_dbus_system_conf();
> @@ -1063,6 +1218,11 @@ start_next:
> monitor_pid = -1;
> }
>
> + if (corpse == controller_pid) {
> + printf("Controller terminated\n");
> + controller_pid = -1;
> + }
> +
> for (i = 0; i < 2; ++i) {
> if (corpse == audio_pid[i]) {
> printf("Audio server %d terminated\n", i);
> --
> 2.53.0
>
>
--
Luiz Augusto von Dentz
^ permalink raw reply [flat|nested] 26+ messages in thread* Re: [PATCH BlueZ v3 09/20] test-runner: use virtio-serial for implementing -u device forwarding
2026-03-24 20:24 ` Luiz Augusto von Dentz
@ 2026-03-24 21:00 ` Pauli Virtanen
0 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-24 21:00 UTC (permalink / raw)
To: Luiz Augusto von Dentz; +Cc: linux-bluetooth
ti, 2026-03-24 kello 16:24 -0400, Luiz Augusto von Dentz kirjoitti:
> Hi Pauli,
>
> On Sun, Mar 22, 2026 at 5:30 PM Pauli Virtanen <pav@iki.fi> wrote:
> >
> > Using pci-serial to forward eg. btvirt sockets is unreliable, as qemu or
> > kernel seems to be sometimes dropping part of the sent data or insert
> > spurious \0 bytes, leading to sporadic errors like:
> >
> > kernel: Bluetooth: hci0: command 0x0c52 tx timeout
> > kernel: Bluetooth: hci0: Opcode 0x0c52 failed: -110
> > btvirt: packet error, unknown type: 0
> >
> > This appears to occur most often when host system is under load, e.g.
> > due to multiple test-runners running at the same time. The problem is
> > not specific to btvirt, but seems to be in the qemu serial device layer
> > vs. kernel interaction.
> >
> > Change test-runner to use virtserialport to forward the btvirt
> > connection inside the VM, as virtio-serial doesn't appear to have these
> > problems.
> >
> > Since it's not a TTY device, we have to do vport <-> tty-with-hci-ldisc
> > forwarding of the data in test-runner, so this becomes a bit more
> > involved.
>
> Lets try to sort this out first before we go into the functional
> framework. Does the vport not working as tty perhaps require a kernel
> driver or something to enable direct operation?
Looks like qemu has different option for the virtio TTY device. We can
replace this patch with the following -> v4
I'll wait for other comments on the series before continuing.
diff --git a/tools/test-runner.c b/tools/test-runner.c
index b3e0b0cfe..0e3bfb8b7 100644
--- a/tools/test-runner.c
+++ b/tools/test-runner.c
@@ -306,7 +306,7 @@ static void start_qemu(void)
testargs);
argv = alloca(sizeof(qemu_argv) +
- (sizeof(char *) * (6 + (num_devs * 4))) +
+ (sizeof(char *) * (8 + (num_devs * 4))) +
(sizeof(char *) * (usb_dev ? 4 : 0)) +
(sizeof(char *) * num_extra_opts));
memcpy(argv, qemu_argv, sizeof(qemu_argv));
@@ -330,14 +330,19 @@ static void start_qemu(void)
argv[pos++] = "-append";
argv[pos++] = (char *) cmdline;
+ if (num_devs) {
+ argv[pos++] = "-device";
+ argv[pos++] = "virtio-serial";
+ }
+
for (i = 0; i < num_devs; i++) {
char *chrdev, *serdev;
chrdev = alloca(48 + strlen(device_path));
sprintf(chrdev, "socket,path=%s,id=bt%d", device_path,
i);
- serdev = alloca(48);
- sprintf(serdev, "pci-serial,chardev=bt%d", i);
+ serdev = alloca(64);
+ sprintf(serdev, "virtconsole,chardev=bt%d,name=bt.%d",
i, i);
argv[pos++] = "-chardev";
argv[pos++] = chrdev;
@@ -910,7 +915,7 @@ static void run_command(char *cmdname, char *home)
}
if (num_devs) {
- const char *node = "/dev/ttyS1";
+ const char *node = "/dev/hvc0";
unsigned int basic_flags, extra_flags;
printf("Attaching BR/EDR controller to %s\n", node);
>
> > ---
> > Makefile.tools | 2 +
> > configure.ac | 9 ++
> > tools/test-runner.c | 300 +++++++++++++++++++++++++++++++++-----------
> > 3 files changed, 241 insertions(+), 70 deletions(-)
> >
> > diff --git a/Makefile.tools b/Makefile.tools
> > index f6de2e685..6e30a535f 100644
> > --- a/Makefile.tools
> > +++ b/Makefile.tools
> > @@ -321,6 +321,8 @@ tools_btgatt_server_SOURCES = tools/btgatt-server.c src/uuid-helper.c
> > tools_btgatt_server_LDADD = src/libshared-mainloop.la \
> > lib/libbluetooth-internal.la
> >
> > +tools_test_runner_LDADD = $(OPENPTY_LIBS)
> > +
> > tools_rctest_LDADD = lib/libbluetooth-internal.la
> >
> > tools_l2test_LDADD = lib/libbluetooth-internal.la
> > diff --git a/configure.ac b/configure.ac
> > index 52de7d665..3bc1f5c44 100644
> > --- a/configure.ac
> > +++ b/configure.ac
> > @@ -251,6 +251,15 @@ AC_ARG_ENABLE(tools, AS_HELP_STRING([--disable-tools],
> > [disable Bluetooth tools]), [enable_tools=${enableval}])
> > AM_CONDITIONAL(TOOLS, test "${enable_tools}" != "no")
> >
> > +openpty_libs=
> > +if (test "${enable_tools}" != "no"); then
> > + AC_CHECK_FUNCS([openpty], [openpty_libs=],
> > + [AC_CHECK_LIB([util], [openpty], [openpty_libs=-lutil],
> > + [AC_CHECK_LIB([bsd], [openpty], [openpty_libs=-lbsd],
> > + [AC_MSG_ERROR([openpty not found])])])])
> > +fi
> > +AC_SUBST(OPENPTY_LIBS, [${openpty_libs}])
> > +
> > AC_ARG_ENABLE(monitor, AS_HELP_STRING([--disable-monitor],
> > [disable Bluetooth monitor]), [enable_monitor=${enableval}])
> > AM_CONDITIONAL(MONITOR, test "${enable_monitor}" != "no")
> > diff --git a/tools/test-runner.c b/tools/test-runner.c
> > index b3e0b0cfe..576313b79 100644
> > --- a/tools/test-runner.c
> > +++ b/tools/test-runner.c
> > @@ -24,6 +24,9 @@
> > #include <getopt.h>
> > #include <poll.h>
> > #include <limits.h>
> > +#include <dirent.h>
> > +#include <pty.h>
> > +#include <stdint.h>
> > #include <sys/wait.h>
> > #include <sys/stat.h>
> > #include <sys/types.h>
> > @@ -306,7 +309,7 @@ static void start_qemu(void)
> > testargs);
> >
> > argv = alloca(sizeof(qemu_argv) +
> > - (sizeof(char *) * (6 + (num_devs * 4))) +
> > + (sizeof(char *) * (8 + (num_devs * 4))) +
> > (sizeof(char *) * (usb_dev ? 4 : 0)) +
> > (sizeof(char *) * num_extra_opts));
> > memcpy(argv, qemu_argv, sizeof(qemu_argv));
> > @@ -330,14 +333,17 @@ static void start_qemu(void)
> > argv[pos++] = "-append";
> > argv[pos++] = (char *) cmdline;
> >
> > + argv[pos++] = "-device";
> > + argv[pos++] = "virtio-serial";
> > +
> > for (i = 0; i < num_devs; i++) {
> > char *chrdev, *serdev;
> >
> > chrdev = alloca(48 + strlen(device_path));
> > sprintf(chrdev, "socket,path=%s,id=bt%d", device_path, i);
> >
> > - serdev = alloca(48);
> > - sprintf(serdev, "pci-serial,chardev=bt%d", i);
> > + serdev = alloca(64);
> > + sprintf(serdev, "virtserialport,chardev=bt%d,name=bt.%d", i, i);
> >
> > argv[pos++] = "-chardev";
> > argv[pos++] = chrdev;
> > @@ -360,65 +366,12 @@ static void start_qemu(void)
> > execve(argv[0], argv, qemu_envp);
> > }
> >
> > -static int open_serial(const char *path)
> > -{
> > - struct termios ti;
> > - int fd, saved_ldisc, ldisc = N_HCI;
> > -
> > - fd = open(path, O_RDWR | O_NOCTTY);
> > - if (fd < 0) {
> > - perror("Failed to open serial port");
> > - return -1;
> > - }
> > -
> > - if (tcflush(fd, TCIOFLUSH) < 0) {
> > - perror("Failed to flush serial port");
> > - close(fd);
> > - return -1;
> > - }
> > -
> > - if (ioctl(fd, TIOCGETD, &saved_ldisc) < 0) {
> > - perror("Failed get serial line discipline");
> > - close(fd);
> > - return -1;
> > - }
> > -
> > - /* Switch TTY to raw mode */
> > - memset(&ti, 0, sizeof(ti));
> > - cfmakeraw(&ti);
> > -
> > - ti.c_cflag |= (B115200 | CLOCAL | CREAD);
> > -
> > - /* Set flow control */
> > - ti.c_cflag |= CRTSCTS;
> > -
> > - if (tcsetattr(fd, TCSANOW, &ti) < 0) {
> > - perror("Failed to set serial port settings");
> > - close(fd);
> > - return -1;
> > - }
> > -
> > - if (ioctl(fd, TIOCSETD, &ldisc) < 0) {
> > - perror("Failed set serial line discipline");
> > - close(fd);
> > - return -1;
> > - }
> > -
> > - printf("Switched line discipline from %d to %d\n", saved_ldisc, ldisc);
> > -
> > - return fd;
> > -}
> > -
> > -static int attach_proto(const char *path, unsigned int proto,
> > +static int attach_proto(int fd, unsigned int proto,
> > unsigned int mandatory_flags,
> > unsigned int optional_flags)
> > {
> > unsigned int flags = mandatory_flags | optional_flags;
> > - int fd, dev_id;
> > -
> > - fd = open_serial(path);
> > - if (fd < 0)
> > - return -1;
> > + int dev_id;
> >
> > if (ioctl(fd, HCIUARTSETFLAGS, flags) < 0) {
> > if (errno == EINVAL) {
> > @@ -895,13 +848,222 @@ static int start_audio_server(pid_t pids[2])
> > return 0;
> > }
> >
> > +static bool find_attach_dev(char path[PATH_MAX])
> > +{
> > + const char *vport_path = "/sys/class/virtio-ports";
> > + struct dirent *entry;
> > + DIR *dir;
> > +
> > + dir = opendir(vport_path);
> > + if (!dir)
> > + return false;
> > +
> > + while ((entry = readdir(dir)) != NULL) {
> > + FILE *f;
> > + char buf[64];
> > + size_t size;
> > +
> > + snprintf(path, PATH_MAX, "%s/%s/name", vport_path,
> > + entry->d_name);
> > + f = fopen(path, "r");
> > + if (!f)
> > + continue;
> > +
> > + size = fread(buf, 1, sizeof(buf) - 1, f);
> > + buf[size] = 0;
> > +
> > + fclose(f);
> > +
> > + if (strncmp(buf, "bt.", 3) == 0) {
> > + snprintf(path, PATH_MAX, "/dev/%s", entry->d_name);
> > + closedir(dir);
> > + return true;
> > + }
> > + }
> > +
> > + closedir(dir);
> > + return false;
> > +}
> > +
> > +static void copy_fd_bidi(int src, int dst)
> > +{
> > + fd_set rfds, wfds;
> > + int fd[2] = { src, dst };
> > + uint8_t buf[2][4096];
> > + size_t size[2] = { 0, 0 };
> > + size_t pos[2] = { 0, 0 };
> > + int i, ret;
> > +
> > + /* Simple copying of data src <-> dst to both directions */
> > +
> > + for (i = 0; i < 2; ++i) {
> > + int flags = fcntl(fd[i], F_GETFL);
> > +
> > + if (fcntl(fd[i], F_SETFL, flags | O_NONBLOCK) < 0) {
> > + perror("fcntl");
> > + goto error;
> > + }
> > + }
> > +
> > + while (1) {
> > + FD_ZERO(&rfds);
> > + FD_ZERO(&wfds);
> > +
> > + for (i = 0; i < 2; ++i) {
> > + if (size[i])
> > + FD_SET(fd[i], &wfds);
> > + else
> > + FD_SET(fd[1 - i], &rfds);
> > + }
> > +
> > + ret = select(FD_SETSIZE, &rfds, &wfds, NULL, NULL);
> > + if (ret < 0) {
> > + if (errno == EINTR)
> > + continue;
> > + perror("select");
> > + goto error;
> > + }
> > +
> > + for (i = 0; i < 2; ++i) {
> > + ssize_t s;
> > +
> > + if (!size[i] && FD_ISSET(fd[1 - i], &rfds)) {
> > + s = read(fd[1 - i], buf[i], sizeof(buf[i]));
> > + if (s >= 0) {
> > + size[i] = s;
> > + pos[i] = 0;
> > + } else if (errno == EINTR) {
> > + /* ok */
> > + } else {
> > + perror("read");
> > + goto error;
> > + }
> > +
> > + }
> > +
> > + if (size[i]) {
> > + s = write(fd[i], buf[i] + pos[i], size[i]);
> > + if (s >= 0) {
> > + size[i] -= s;
> > + pos[i] += s;
> > + } else if (errno == EINTR || errno == EAGAIN
> > + || errno == EWOULDBLOCK) {
> > + /* ok */
> > + } else {
> > + perror("write");
> > + goto error;
> > + }
> > + }
> > + }
> > + }
>
> It doesn't look that great if you ask me. I think this requires us to
> copy significantly more data than before, likely resulting in worse
> performance, which could be detrimental in resource-limited
> environments like GitHub CI/CD.
>
> > + return;
> > +
> > +error:
> > + fprintf(stderr, "Bluetooth controller forward terminated with error\n");
> > + exit(1);
> > +}
> > +
> > +static int start_controller_forward(const char *path, pid_t *controller_pid)
> > +{
> > + struct termios ti;
> > + pid_t pid;
> > + int src = -1, dst = -1, fd = -1;
> > + int ret, saved_ldisc, ldisc = N_HCI;
> > +
> > + /* virtio-serial ports cannot be used for HCI line disciple, so
> > + * openpty() serial device and forward data to/from it.
> > + */
> > +
> > + src = open(path, O_RDWR);
> > + if (src < 0)
> > + goto error;
> > +
> > + /* Raw mode TTY */
> > + memset(&ti, 0, sizeof(ti));
> > + cfmakeraw(&ti);
> > + ti.c_cflag |= B115200 | CLOCAL | CREAD;
> > +
> > + /* With flow control */
> > + ti.c_cflag |= CRTSCTS;
> > +
> > + ret = openpty(&dst, &fd, NULL, &ti, NULL);
> > + if (ret < 0)
> > + goto error;
> > +
> > + if (ioctl(fd, TIOCGETD, &saved_ldisc) < 0) {
> > + perror("Failed get serial line discipline");
> > + goto error;
> > + }
> > +
> > + if (ioctl(fd, TIOCSETD, &ldisc) < 0) {
> > + perror("Failed set serial line discipline");
> > + goto error;
> > + }
> > +
> > + printf("Switched line discipline from %d to %d\n", saved_ldisc, ldisc);
> > +
> > + pid = fork();
> > + if (pid < 0) {
> > + perror("Failed to fork new process");
> > + goto error;
> > + } else if (pid == 0) {
> > + close(fd);
> > + copy_fd_bidi(src, dst);
> > + exit(0);
> > + }
> > +
> > + *controller_pid = pid;
> > +
> > + close(src);
> > + close(dst);
> > + return fd;
> > +
> > +error:
> > + if (src >= 0)
> > + close(src);
> > + if (dst >= 0)
> > + close(dst);
> > + if (fd >= 0)
> > + close(fd);
> > + return -1;
> > +}
> > +
> > +static int attach_controller(pid_t *controller_pid)
> > +{
> > + unsigned int basic_flags, extra_flags;
> > + char path[PATH_MAX];
> > + int fd;
> > +
> > + *controller_pid = -1;
> > +
> > + if (!find_attach_dev(path)) {
> > + printf("Failed to find Bluetooth controller virtio\n");
> > + return -1;
> > + }
> > +
> > + printf("Forwarding Bluetooth controller from %s\n", path);
> > +
> > + fd = start_controller_forward(path, controller_pid);
> > + if (fd < 0) {
> > + printf("Failed to forward Bluetooth controller\n");
> > + return -1;
> > + }
> > +
> > + basic_flags = (1 << HCI_UART_RESET_ON_INIT);
> > + extra_flags = (1 << HCI_UART_VND_DETECT);
> > +
> > + printf("Attaching Bluetooth controller\n");
> > +
> > + return attach_proto(fd, HCI_UART_H4, basic_flags, extra_flags);
> > +}
> > +
> > static void run_command(char *cmdname, char *home)
> > {
> > char *argv[9], *envp[3];
> > int pos = 0, idx = 0;
> > int serial_fd;
> > pid_t pid, dbus_pid, daemon_pid, monitor_pid, emulator_pid,
> > - dbus_session_pid, audio_pid[2];
> > + dbus_session_pid, audio_pid[2], controller_pid;
> > int i;
> >
> > if (!home) {
> > @@ -910,18 +1072,11 @@ static void run_command(char *cmdname, char *home)
> > }
> >
> > if (num_devs) {
> > - const char *node = "/dev/ttyS1";
> > - unsigned int basic_flags, extra_flags;
> > -
> > - printf("Attaching BR/EDR controller to %s\n", node);
> > -
> > - basic_flags = (1 << HCI_UART_RESET_ON_INIT);
> > - extra_flags = (1 << HCI_UART_VND_DETECT);
> > -
> > - serial_fd = attach_proto(node, HCI_UART_H4, basic_flags,
> > - extra_flags);
> > - } else
> > + serial_fd = attach_controller(&controller_pid);
> > + } else {
> > serial_fd = -1;
> > + controller_pid = -1;
> > + }
> >
> > if (start_dbus) {
> > create_dbus_system_conf();
> > @@ -1063,6 +1218,11 @@ start_next:
> > monitor_pid = -1;
> > }
> >
> > + if (corpse == controller_pid) {
> > + printf("Controller terminated\n");
> > + controller_pid = -1;
> > + }
> > +
> > for (i = 0; i < 2; ++i) {
> > if (corpse == audio_pid[i]) {
> > printf("Audio server %d terminated\n", i);
> > --
> > 2.53.0
> >
> >
>
--
Pauli Virtanen
^ permalink raw reply related [flat|nested] 26+ messages in thread
* [PATCH BlueZ v3 10/20] doc: enable KVM paravirtualization & clock support in tester kernel config
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (8 preceding siblings ...)
2026-03-22 21:29 ` [PATCH BlueZ v3 09/20] test-runner: use virtio-serial for implementing -u device forwarding Pauli Virtanen
@ 2026-03-22 21:29 ` Pauli Virtanen
2026-03-22 21:30 ` [PATCH BlueZ v3 11/20] doc: add functional/integration testing documentation Pauli Virtanen
` (9 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:29 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Enable KVM guest and PTP options in tester kernel config.
This allows synchronizing tester VM guest with host clock, needed for
testers that want to compare timestamps outside the VM guest.
---
doc/ci.config | 8 ++++++++
doc/test-runner.rst | 16 ++++++++++++++++
doc/tester.config | 8 ++++++++
3 files changed, 32 insertions(+)
diff --git a/doc/ci.config b/doc/ci.config
index a48c1af9d..bb3cb221f 100644
--- a/doc/ci.config
+++ b/doc/ci.config
@@ -8,6 +8,14 @@ CONFIG_VIRTIO=y
CONFIG_VIRTIO_PCI=y
CONFIG_VIRTIO_CONSOLE=y
+CONFIG_HYPERVISOR_GUEST=y
+CONFIG_PARAVIRT=y
+CONFIG_KVM_GUEST=y
+
+CONFIG_PTP_1588_CLOCK=y
+CONFIG_PTP_1588_CLOCK_KVM=y
+CONFIG_PTP_1588_CLOCK_VMCLOCK=y
+
CONFIG_NET=y
CONFIG_INET=y
diff --git a/doc/test-runner.rst b/doc/test-runner.rst
index d030787a4..60f18683c 100644
--- a/doc/test-runner.rst
+++ b/doc/test-runner.rst
@@ -122,6 +122,22 @@ options may be useful:
CONFIG_DEBUG_MUTEXES=y
CONFIG_KASAN=y
+Other
+-----
+
+For tests requiring accurate time inside the VM, possible with KVM:
+
+.. code-block::
+
+ CONFIG_HYPERVISOR_GUEST=y
+ CONFIG_PARAVIRT=y
+ CONFIG_KVM_GUEST=y
+
+ CONFIG_PTP_1588_CLOCK=y
+ CONFIG_PTP_1588_CLOCK_KVM=y
+ CONFIG_PTP_1588_CLOCK_VMCLOCK=y
+
+
EXAMPLES
========
diff --git a/doc/tester.config b/doc/tester.config
index 015e7cc1a..0cf5e2723 100644
--- a/doc/tester.config
+++ b/doc/tester.config
@@ -3,6 +3,14 @@ CONFIG_VIRTIO=y
CONFIG_VIRTIO_PCI=y
CONFIG_VIRTIO_CONSOLE=y
+CONFIG_HYPERVISOR_GUEST=y
+CONFIG_PARAVIRT=y
+CONFIG_KVM_GUEST=y
+
+CONFIG_PTP_1588_CLOCK=y
+CONFIG_PTP_1588_CLOCK_KVM=y
+CONFIG_PTP_1588_CLOCK_VMCLOCK=y
+
CONFIG_NET=y
CONFIG_INET=y
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 11/20] doc: add functional/integration testing documentation
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (9 preceding siblings ...)
2026-03-22 21:29 ` [PATCH BlueZ v3 10/20] doc: enable KVM paravirtualization & clock support in tester kernel config Pauli Virtanen
@ 2026-03-22 21:30 ` Pauli Virtanen
2026-03-22 21:30 ` [PATCH BlueZ v3 12/20] test: add functional/integration testing framework Pauli Virtanen
` (8 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:30 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Add documentation for functional/integration test suite.
---
doc/test-functional.rst | 780 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 780 insertions(+)
create mode 100644 doc/test-functional.rst
diff --git a/doc/test-functional.rst b/doc/test-functional.rst
new file mode 100644
index 000000000..139f99c40
--- /dev/null
+++ b/doc/test-functional.rst
@@ -0,0 +1,780 @@
+===============
+test-functional
+===============
+
+**test-functional** [*OPTIONS*]
+
+DESCRIPTION
+===========
+
+**test-functional(1)** is used for functional testing of BlueZ and
+kernel using multiple virtual machine environments, connected by real
+or virtual controllers.
+
+OPTIONS
+=======
+
+The `test-functional` script simply runs `Pytest
+<https://pytest.org>`__ which can take the following options:
+https://docs.pytest.org/en/stable/reference/reference.html#command-line-flags
+
+The following additional options apply:
+
+:--kernel=<image>: Kernel image (or built Linux source tree root) to
+ use. See **test-runner(1)** and `tester.config` for required
+ kernel config.
+
+ If not provided, value from `FUNCTIONAL_TESTING_KERNEL`
+ environment variable is used. If none, no image is used.
+
+:--usb=hci0,hci1: USB controllers to use in tests that require use of
+ real controllers.
+
+ If not provided, value from `FUNCTIONAL_TESTING_CONTROLLERS`
+ environment variable is used. If none, all USB controllers
+ with suitable permissions are considered.
+
+:--force-usb: Force tests to use USB controllers instead of `btvirt`.
+
+:--vm-timeout=<seconds>: Specify timeout for communication with VM hosts.
+
+:--log-filter=[+-]<pattern>,[+-]<pattern>,...: Allow/deny lists
+ for filtering logging output. The pattern is a shell glob matching
+ to the logger names.
+
+:--build-dir=<path>: Path to build directory where to search for BlueZ
+ executables.
+
+:--list: Output brief lists of existing tests.
+
+Tests that require kernel image or USB controllers are skipped if none
+are available. Normally, tests use `btvirt`.
+
+VM instances share a directory ``/run/shared`` with host machine,
+located on host usually in ``/tmp/bluez-func-test-*/shared-*``. Core
+dumps etc. are copied out from it before test instance is shut down.
+
+
+REQUIREMENTS
+============
+
+General
+-------
+
+The following are needed:
+
+- QEmu (x86_64)
+- ``dbus-daemon`` available
+
+Recommended:
+
+- KVM-enabled x86_64 host system
+- Preferably built BlueZ source tree
+- ``chronyd`` available
+- ``util-linux`` tools available
+- ``agetty`` available
+
+Python
+------
+
+The following Python packages are required:
+
+.. code-block::
+
+ pytest
+ pexpect
+ dbus-python
+
+To install them via pip::
+
+ python3 -m pip install -r test/functional/requirements.txt
+
+On Fedora / RHEL::
+
+ sudo dnf install python3-pytest python3-pexpect python3-dbus
+
+
+Kernel
+------
+
+The **test-functional(1)** tool requires a kernel image with similar
+config as **test-runner(1)**. Simplest setup is
+
+.. code-block::
+
+ cp ../bluez/doc/tester.config .config
+ make olddefconfig
+ make -j8
+
+To get log timestamps right, the kernel should have the following
+configuration enabled:
+
+.. code-block::
+
+ CONFIG_HYPERVISOR_GUEST=y
+ CONFIG_PARAVIRT=y
+ CONFIG_KVM_GUEST=y
+
+ CONFIG_PTP_1588_CLOCK=y
+ CONFIG_PTP_1588_CLOCK_KVM=y
+ CONFIG_PTP_1588_CLOCK_VMCLOCK=y
+
+USB
+---
+
+Some tests may require a hardware controller instead of the virtual `btvirt` one.
+
+
+EXAMPLES
+========
+
+Run all tests
+-------------
+
+.. code-block::
+
+ $ test/test-functional --kernel=/pathto/bzImage
+
+ $ export FUNCTIONAL_TESTING_KERNEL=/pathto/bzImage
+ $ test/test-functional
+
+Show output during run
+----------------------
+
+.. code-block::
+
+ $ test/test-functional --log-cli-level=0
+
+Show only specific loggers:
+
+.. code-block::
+
+ $ test/test-functional --log-cli-level=0 --log-filter=rpc,host
+
+ $ test/test-functional --log-cli-level=0 --log-filter=*.bluetoothctl
+
+Filter out loggers:
+
+.. code-block::
+
+ $ test/test-functional --log-cli-level=0 --log-filter=-host
+
+ $ test/test-functional --log-cli-level=0 --log-filter=host,-host.*.1
+
+Run selected tests
+------------------
+
+.. code-block::
+
+ $ test/test-functional test/functional/test_cli_simple.py::test_bluetoothctl_script_show
+
+ $ test/test-functional -k test_bluetoothctl_script_show
+
+ $ test/test-functional -k 'test_btmgmt or test_bluetoothctl'
+
+Don't run tests with a given marker:
+
+.. code-block::
+
+ $ test/test-functional -m "not pipewire"
+
+Don't run known-failing tests:
+
+.. code-block::
+
+ $ test/test-functional -m "not xfail"
+
+Note that otherwise known-failing tests would be run, but with
+failures suppressed.
+
+Run previously failed and stop on failure
+-----------------------------------------
+
+.. code-block::
+
+ $ test/test-functional -x --ff
+
+List all tests
+--------------
+
+.. code-block::
+
+ $ test/test-functional --list
+
+Show errors from know-failing test
+----------------------------------
+
+.. code-block::
+
+ $ test/test-functional --runxfail -k test_btmgmt_info
+
+Redirect USB devices
+--------------------
+
+.. code-block::
+
+ $ test/test-functional --usb=hci0,hci1
+
+ $ export FUNCTIONAL_TESTING_CONTROLLERS=hci0,hci1
+ $ test/test-functional -vv
+
+This does not require running as root. Changing device permissions is
+sufficient. In verbose mode (``-vv``) some instructions are printed.
+
+Run all tests using the USB controllers:
+
+.. code-block::
+
+ $ test/test-functional --usb=hci0,hci1 --force-usb
+
+Run tests in parallel
+---------------------
+
+pytest-xdist is required for parallel execution. To run:
+
+.. code-block::
+
+ $ test/test-functional -n auto
+
+To reduce VM setup/teardowns:
+
+.. code-block::
+
+ $ test/test-functional -n auto --dist loadgroup
+
+Logging in to a test VM instance
+--------------------------------
+
+While test is running:
+
+.. code-block::
+
+ $ test/test-functional-attach
+
+For this to be useful, usually, you need to pause the test
+e.g. by running with ``--trace`` option.
+
+To do it manually, when starting the tester will log a line like::
+
+ TTY: socat /tmp/bluez-func-test-q658swgi/bluez-func-test-tty-0 STDIO,rawer
+
+with the location of the socket where the serial is connected to.
+
+WRITING TESTS
+=============
+
+The functional tests are written in files (test modules) names
+`test/functional/test_*.py`. They are written using standard Pytest
+style. See https://docs.pytest.org/en/stable/getting-started.html
+
+Example: Virtual machines
+-------------------------
+
+.. code-block:: python
+
+ from pytest_bluez import host_config, Bluetoothd, Bluetoothctl
+
+ @host_config(
+ [Bluetoothd(), Bluetoothctl()],
+ [Bluetoothd(), Bluetoothctl()],
+ )
+ def test_bluetoothctl_pair(hosts):
+ host0, host1 = hosts
+
+ host0.bluetoothctl.send("scan on\n")
+ host0.bluetoothctl.expect(f"Controller {host0.bdaddr.upper()} Discovering: yes")
+
+ host1.bluetoothctl.send("pairable on\n")
+ host1.bluetoothctl.expect("Changing pairable on succeeded")
+ host1.bluetoothctl.send("discoverable on\n")
+ host1.bluetoothctl.expect(f"Controller {host1.bdaddr.upper()} Discoverable: yes")
+
+ host0.bluetoothctl.expect(f"Device {host1.bdaddr.upper()}")
+ host0.bluetoothctl.send(f"pair {host1.bdaddr}\n")
+
+ idx, m = host0.bluetoothctl.expect(r"Confirm passkey (\d+).*:")
+ key = m[0].decode("utf-8")
+
+ host1.bluetoothctl.expect(f"Confirm passkey {key}")
+
+ host0.bluetoothctl.send("yes\n")
+ host1.bluetoothctl.send("yes\n")
+
+ host0.bluetoothctl.expect("Pairing successful")
+
+The test declares a VM setup with two Qemu instances, where both hosts
+run bluetoothd and start a bluetoothctl process. The Qemu instances
+have `btvirt` virtual BT controllers and can see each other.
+
+The test itself runs on the parent host.
+
+The `host0/1.bluetoothctl.*` commands invoke RPC calls to one of the
+the two VM instances. In this case, they are controlling the
+`bluetoothctl` process using `pexpect` library to deal with its
+command line.
+
+When the test body finishes executing, the test passes. Or, it fails
+if any ``assert`` statement fails or an error is raised. For example,
+above ``RemoteError`` due to bluetoothctl not proceeding as expected
+in pairing is possible.
+
+The host configuration (bluetoothd + bluetoothctl above) is torn down
+between test (SIGTERM/SIGKILL sent etc.).
+
+By default the VM instance itself continues running, and may be used
+for other tests that share the same VM setup.
+
+Generally, the framework automatically orders the tests so that the VM
+setup does not need to be restarted unless needed.
+
+
+Example host plugin
+-------------------
+
+The `host.bluetoothctl` implementation used above is as follows:
+
+.. code-block:: python
+
+ from pytest_bluez import HostPlugin, Bluetoothd
+
+ class Bluetoothctl(Pexpect):
+ # Declare unique plugin name
+ name = "bluetoothctl"
+
+ # Declare plugin dependencies to be loaded first
+ depends = [Bluetoothd()]
+
+ # These run on parent host side:
+
+ def __init__(self, subdir, name):
+ self.exe = utils.find_exe(subdir, name)
+
+ def presetup(self):
+ pass
+
+ # These run on VM side at setup/teardown:
+
+ def setup(self, impl):
+ self.log = logging.getLogger(self.name)
+ self.log_stream = utils.LogStream(self.name)
+ self.ctl = pexpect.spawn(self.exe, logfile=self.log_stream.stream)
+
+ def teardown(self):
+ self.ctl.terminate()
+
+ # These define custom RPC methods that can be called
+
+ def expect(self, *a, **kw):
+ ret = self.ctl.expect(*a, **kw)
+ self.log.debug("match found")
+ return ret, self.ctl.match.groups()
+
+ def send(self, *a, **kw):
+ return self.ctl.send(*a, **kw)
+
+
+
+Host plugins are for injecting code to run on the VM side test hosts.
+The host plugins have scope of one test. The VM side test framework
+sends SIGTERM and SIGKILL to all processes in the test process group
+to reset the state between each test.
+
+The plugins are declared by inheriting from `HostPlugin`. Their
+`__init__()` is supposed to only store declarative configuration on
+`self` and runs on parent side early in the test discovery phase. The
+`presetup` runs on parent side in test setup phase, before VM
+environment is started. The plugin can for example do
+`pytest.skip(reason="something")` to skip the test.
+
+The `setup()` and `teardown()` methods run on VM-side at host
+environment start and end. All other methods can be invoked via RPC
+by the parent tester, and any values returned by them are passed via
+RPC back to the parent.
+
+To load a plugin to a VM host, pass it to `host_config()` in the
+declaration of a given test.
+
+Test fixtures
+=============
+
+The following test fixtures are used to deal with spawning VM hosts:
+
+hosts
+-----
+
+.. code-block::
+
+ Session-scope fixture that expands to a list of VM host proxies
+ (`HostProxy`), with configuration as specified in `host_config`. The
+ VM instances used may be reused by other tests. The userspace test
+ runner is torn down between tests.
+
+ Example:
+
+ def test_something(hosts):
+ host0 = hosts[0]
+ host1 = hosts[1]
+
+hosts_once
+----------
+
+.. code-block::
+
+ def test_something(hosts_once):
+ host0 = hosts_once[0]
+ host1 = hosts_once[1]
+
+Function-scope fixture. Same as `hosts`, but spawn separate VM
+instances for this test only.
+
+Others
+------
+
+The following fixtures are defined, but mainly for use as dependencies
+to `hosts`: `kernel` (selected kernel image), `usb_indices` (selected
+USB controllers), `host_setup` (current host plugin configurations),
+`vm_setup` (VM host configuration), `vm` (VM instances without
+userspace setup), `vm_once` (same but with function scope).
+
+Utilities
+=========
+
+In addition to standard Pytest features, the following items are
+available in the `pytest_bluez` module.
+
+host_config
+-----------
+
+.. code-block::
+
+ @host_config(*host_setup, hw=False, reuse=False)
+
+ Declare host configuration.
+
+ Args:
+ *host_setup: each argument is a list of plugins to be loaded on a host.
+ The number of arguments specifies the number of hosts.
+ hw (bool): whether to require hardware BT controller
+ reuse (bool): whether to define a setup where the test host processes
+ are not required to be torn down between tests. This is only useful
+ for tests that do not perturb e.g. bluetoothd state too much.
+
+ Returns:
+ callable: decorator setting pytest attributes
+
+ Example:
+
+ @host_config([Bluetoothd()], [Bluetoothd()])
+ def test_something(hosts):
+ host0, host1 = hosts
+
+ Example:
+
+ # Allow not restarting Bluetoothd between tests sharing this configuration
+ base_config = host_config([Bluetoothd()], reuse=True)
+
+ @base_config
+ def test_one(hosts):
+ host0, = hosts
+
+ @base_config
+ def test_two(hosts):
+ # Note: uses same Bluetoothd() instance as above
+ host0, = hosts
+
+parametrized_host_config
+------------------------
+
+.. code-block::
+
+ Declare parametrized host configurations.
+
+ See https://docs.pytest.org/en/stable/how-to/parametrize.html for the
+ concept.
+
+ Args:
+ param_host_setups (list): list of host setups
+ hw (bool): whether to require hardware BT controller
+ reuse (bool): whether to define a setup where the test host processes
+ are not required to be torn down between tests. This is only useful
+ for tests that do not perturb e.g. bluetoothd state too much.
+
+ Returns:
+ callable: decorator setting pytest attributes
+
+HostProxy
+---------
+
+.. code-block::
+
+ class HostProxy:
+ """
+ Parent-side proxy for VM host: load plugins, RPC calls to plugins
+ """
+
+ def load(self, plugin: HostPlugin):
+ """
+ Load given plugin to the VM host synchronously.
+ """
+
+ def start_load(self, plugin: HostPlugin):
+ """
+ Initiate loading the given plugin to the VM host. Use
+ `wait_load` to wait for completion and make loaded plugins
+ usable.
+
+ """
+
+ def wait_load(self):
+ """
+ Wait for plugin loads to complete, and make plugins available.
+ """
+
+ def close(self)
+ """
+ Shutdown this VM host tester instance.
+ """
+
+ def __getattr__(self, name):
+ """
+ Get a proxy attribute for one of the loaded plugins
+ """
+
+Parent host-side representation of one VM host with loadable plugins.
+
+Plugins are usually loaded based on `host_setup`, but can also be
+loaded during the test itself.
+
+Loaded plugins appear as attributes on the host proxy.
+
+find_exe
+--------
+
+.. code-block::
+
+ from pytest_bluez import find_exe
+ bluetoothctl = find_exe("client", "bluetoothctl")
+
+Find absolute path to the given executable, either within BlueZ build
+directory or on host.
+
+
+mainloop_invoke
+-------------
+
+.. code-block::
+
+ Blocking invoke of `func` in GLib main loop.
+
+ Note:
+
+ GLib main loop is only available for VM host plugins, not in tester.
+
+ Example:
+
+ value = mainloop_invoke(lambda: 123)
+ assert value == 123
+
+ Warning:
+ dbus-python **MUST** be used only from the GLib main loop,
+ as the library has concurrency bugs. All functions using it
+ **MUST** either run from GLib main loop eg. via mainloop_wrap
+
+mainloop_wrap
+-------------
+
+.. code-block::
+
+ Wrap function to run in GLib main loop thread
+
+ Note:
+
+ GLib main loop is only available for VM host plugins, not in tester.
+
+ Example:
+
+ @mainloop_wrap
+ def func():
+ bus = dbus.SystemBus()
+
+mainloop_wrap
+-------------
+
+.. code-block::
+
+ Wrap function to assert it runs from GLib main loop
+
+ Note:
+
+ GLib main loop is only available for VM host plugins, not in tester.
+
+ Example:
+
+ @mainloop_assert
+ def func():
+ bus = dbus.SystemBus()
+
+LogStream
+---------
+
+.. code-block::
+
+ from pytest_bluez import LogStream
+
+ log_stream = LogStream("bluetoothctl")
+ subprocess.run(["bluetoothctl", "show"], stdout=log_stream.stream)
+
+Utility to redirect a stream to logging with accurate kernel-provided
+timestamps.
+
+RemoteError
+-----------
+
+.. code-block::
+
+ from pytest_bluez import RemoteError
+
+ try:
+ host.call(foo)
+ except RemoteError as exc:
+ print(exc.traceback)
+ original_exception = exc.exc
+
+Exception raised on the VM side, passed through RPC. Properties:
+`traceback` is a traceback string and `exc` is the original exception
+instance raised on the remote side.
+
+Host plugins
+============
+
+The following host plugins are available:
+
+HostPlugin
+----------
+
+Base class for host plugins. See also example above.
+
+.. code-block::
+
+ class HostPlugin:
+ """
+ Plugin to insert code to VM host side.
+
+ Attributes:
+ name (str): unique name for the plugin
+ depends (tuple[HostPlugin]): plugins to be loaded before this one
+ value (object): object to appear as HostProxy attribute on parent side.
+ If None, the plugin is represented by a proxy object that does RPC
+ calls. Otherwise, must be a serializable value.
+
+ """
+
+ name = None
+ depends = ()
+ value = None
+
+ def __init__(self):
+ """
+ Configure plugin (runs on parent host side). This is
+ called at test discovery time, so should mainly store static
+ data.
+
+ """
+ pass
+
+ def presetup(self):
+ """
+ Parent host-side setup, before VM environment is started. May
+ use pytest.skip() to skip tests in case plugin cannot be set up.
+
+ """
+ pass
+
+ def setup(self, impl):
+ """
+ VM-side setup
+
+ Args:
+ impl (Implementation): plugin host object
+ """
+ pass
+
+ def teardown(self):
+ """VM-side teardown"""
+ pass
+
+Bdaddr
+------
+
+Host plugin providing ``host.bdaddr``.
+Loaded by default.
+
+Bluetoothctl
+------------
+
+.. code-block::
+
+ class Bluetoothctl(HostPlugin)
+ def expect(self, *a, **kw)
+ def send(self, *a, **kw)
+
+Host plugin for starting and controlling `bluetoothctl` with pexpect.
+
+Bluetoothd
+----------
+
+Host plugin starting Bluetoothd.
+
+Call
+----
+
+.. code-block::
+
+ class Call(HostPlugin)
+
+ Host plugin providing ``host.call(func, *args, **kw)`` and `call_async`
+ which invoke the given functions on VM host side. Loaded by default.
+
+ Example:
+
+ result = host0.call(my_func, 1, 2, 3)
+
+ Example:
+
+ result_async = host0.call(my_func, 1, 2, 3, sync=False)
+ ...
+ result = result_async.wait()
+
+DbusSession
+-----------
+
+Host plugin providing session DBus, at address
+`impl["dbus-session"].address`.
+
+DbusSystem
+----------
+
+Host plugin providing system DBus, at address
+`impl["dbus-system"].address`.
+
+Pexpect
+-------
+
+.. code-block::
+
+ class Pexpect(env.HostPlugin)
+
+ Host plugin for starting and controlling processes with pexpect.
+
+ Example:
+
+ btmgmt = host0.pexpect.spawn(find_exe("tools", "btmgmt"))
+ btmgmt.send("info\n")
+ btmgmt.expect("hci0")
+ btmgmt.close()
+
+Rcvbuf
+------
+
+Host plugin setting pipe buffer size defaults.
+Loaded by default.
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 12/20] test: add functional/integration testing framework
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (10 preceding siblings ...)
2026-03-22 21:30 ` [PATCH BlueZ v3 11/20] doc: add functional/integration testing documentation Pauli Virtanen
@ 2026-03-22 21:30 ` Pauli Virtanen
2026-03-22 21:30 ` [PATCH BlueZ v3 13/20] test: functional: add Pipewire-using audio streaming tests Pauli Virtanen
` (7 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:30 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Add framework for writing tests simulating "real" environments where
BlueZ and other parts of the stack run on different virtual machine
hosts that communicate with each other.
Implementation is factored into test/pytest_bluez/ support plugin that's
in principle reusable for other projects, and test/functional/ actual
test files.
Implements:
- RPC communication with tester instances running each of the VM hosts,
so that tests can be written on the parent host which coordinates the
execution.
- Extensible way to add stateful test-specific code inside the VM
instances
- Logging control: output from different processes running inside the VM
are separated and can be filtered.
- Test runner framework with Pytest, factored into a pytest plugin
- Grouping tests to minimize VM reboots
- Redirecting USB controllers to use for testing
There is no requirement that the tests spawn VM instances. The test
runner finds any tests in test/**/test_*.py
---
doc/test-functional.rst | 7 +-
test/functional/__init__.py | 2 +
test/functional/requirements.txt | 4 +
test/functional/test_bluetoothctl_vm.py | 152 +++++
test/functional/test_btmgmt_vm.py | 30 +
test/pytest.ini | 13 +
test/pytest_bluez/__init__.py | 10 +
test/pytest_bluez/env.py | 784 ++++++++++++++++++++++++
test/pytest_bluez/host_plugins.py | 566 +++++++++++++++++
test/pytest_bluez/plugin.py | 559 +++++++++++++++++
test/pytest_bluez/rpc.py | 385 ++++++++++++
test/pytest_bluez/runner.py | 20 +
test/pytest_bluez/tests/__init__.py | 2 +
test/pytest_bluez/tests/test_rpc.py | 62 ++
test/pytest_bluez/tests/test_utils.py | 16 +
test/pytest_bluez/utils.py | 706 +++++++++++++++++++++
test/test-functional | 7 +
test/test-functional-attach | 52 ++
18 files changed, 3374 insertions(+), 3 deletions(-)
create mode 100644 test/functional/__init__.py
create mode 100644 test/functional/requirements.txt
create mode 100644 test/functional/test_bluetoothctl_vm.py
create mode 100644 test/functional/test_btmgmt_vm.py
create mode 100644 test/pytest.ini
create mode 100644 test/pytest_bluez/__init__.py
create mode 100644 test/pytest_bluez/env.py
create mode 100644 test/pytest_bluez/host_plugins.py
create mode 100644 test/pytest_bluez/plugin.py
create mode 100644 test/pytest_bluez/rpc.py
create mode 100644 test/pytest_bluez/runner.py
create mode 100644 test/pytest_bluez/tests/__init__.py
create mode 100644 test/pytest_bluez/tests/test_rpc.py
create mode 100644 test/pytest_bluez/tests/test_utils.py
create mode 100644 test/pytest_bluez/utils.py
create mode 100755 test/test-functional
create mode 100755 test/test-functional-attach
diff --git a/doc/test-functional.rst b/doc/test-functional.rst
index 139f99c40..f51401930 100644
--- a/doc/test-functional.rst
+++ b/doc/test-functional.rst
@@ -81,9 +81,10 @@ The following Python packages are required:
.. code-block::
- pytest
- pexpect
- dbus-python
+ pytest>=8
+ pexpect
+ dbus-python
+ PyGObject>=3.40
To install them via pip::
diff --git a/test/functional/__init__.py b/test/functional/__init__.py
new file mode 100644
index 000000000..fe1c85178
--- /dev/null
+++ b/test/functional/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
diff --git a/test/functional/requirements.txt b/test/functional/requirements.txt
new file mode 100644
index 000000000..b796e03bb
--- /dev/null
+++ b/test/functional/requirements.txt
@@ -0,0 +1,4 @@
+pytest>=8
+pexpect
+dbus-python
+PyGObject>=3.40
diff --git a/test/functional/test_bluetoothctl_vm.py b/test/functional/test_bluetoothctl_vm.py
new file mode 100644
index 000000000..6049e6793
--- /dev/null
+++ b/test/functional/test_bluetoothctl_vm.py
@@ -0,0 +1,152 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+Tests for bluetoothctl using VM instances
+"""
+import sys
+import re
+import pytest
+import subprocess
+import tempfile
+import warnings
+
+import time
+import logging
+
+
+from pytest_bluez import host_config, find_exe, run, Bluetoothd, Bluetoothctl
+
+pytestmark = [pytest.mark.vm]
+
+bluetoothctl = find_exe("client", "bluetoothctl")
+
+bluetoothd_reuse_config = host_config([Bluetoothd()], reuse=True)
+
+
+@host_config(
+ [Bluetoothctl()],
+ [Bluetoothctl()],
+)
+def test_bluetoothctl_pair_bredr(hosts):
+ host0, host1 = hosts
+
+ host0.bluetoothctl.send("scan on\n")
+ host0.bluetoothctl.expect(f"Controller {host0.bdaddr.upper()} Discovering: yes")
+
+ host1.bluetoothctl.send("pairable on\n")
+ host1.bluetoothctl.expect("Changing pairable on succeeded")
+ host1.bluetoothctl.send("discoverable on\n")
+ host1.bluetoothctl.expect(f"Controller {host1.bdaddr.upper()} Discoverable: yes")
+
+ host0.bluetoothctl.expect(f"Device {host1.bdaddr.upper()}")
+ host0.bluetoothctl.send(f"pair {host1.bdaddr}\n")
+
+ idx, m = host0.bluetoothctl.expect(r"Confirm passkey (\d+).*:")
+ key = m[0].decode("utf-8")
+
+ host1.bluetoothctl.expect(f"Confirm passkey {key}")
+
+ host0.bluetoothctl.send("yes\n")
+ host1.bluetoothctl.send("yes\n")
+
+ host0.bluetoothctl.expect("Pairing successful")
+
+
+@host_config(
+ [Bluetoothd(conf="[General]\nControllerMode = le\n"), Bluetoothctl()],
+ [Bluetoothd(conf="[General]\nControllerMode = le\n"), Bluetoothctl()],
+)
+def test_bluetoothctl_pair_le(hosts):
+ host0, host1 = hosts
+
+ host0.bluetoothctl.send("scan on\n")
+ host0.bluetoothctl.expect(f"Controller {host0.bdaddr.upper()} Discovering: yes")
+
+ host1.bluetoothctl.send("advertise on\n")
+ host1.bluetoothctl.expect("Advertising object registered")
+
+ host0.bluetoothctl.expect(f"Device {host1.bdaddr.upper()}")
+ host0.bluetoothctl.send(f"pair {host1.bdaddr.upper()}\n")
+
+ # BUG!: if controller is power cycled off/on at boot (before bluetoothd)
+ # BUG!: which is what the tester here does,
+ # BUG!: bluetoothd MGMT command to enable Secure Connections Host Support
+ # BUG!: fails and we are left with legacy passkey. It seems we get randomly
+ # BUG!: one of these depending on what state controller/kernel were before
+ # BUG!: btmgmt power off/on
+
+ idx, m = host0.bluetoothctl.expect(
+ [r"\[agent\].*Passkey:.*m(\d+)", r"Confirm passkey (\d+).*:"]
+ )
+ key = m[0].decode("utf-8")
+
+ if idx == 0:
+ warnings.warn(
+ "BUG: we got passkey authentication, bluetoothd/kernel should be fixed"
+ )
+ host1.bluetoothctl.expect(r"\[agent\] Enter passkey \(number in 0-999999\):")
+ host1.bluetoothctl.send(f"{key}\n")
+ else:
+ host1.bluetoothctl.expect(f"Confirm passkey {key}")
+
+ host0.bluetoothctl.send("yes\n")
+ host1.bluetoothctl.send("yes\n")
+
+ host0.bluetoothctl.expect("Pairing successful")
+
+
+def run_bluetoothctl(*args):
+ return run(
+ [bluetoothctl] + list(args),
+ stdout=subprocess.PIPE,
+ stdin=subprocess.DEVNULL,
+ encoding="utf-8",
+ )
+
+
+def run_bluetoothctl_script(script):
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as f:
+ f.write(script)
+ f.write("\nquit")
+ f.flush()
+ return run_bluetoothctl("--init-script", f.name)
+
+
+@bluetoothd_reuse_config
+def test_bluetoothctl_show(hosts):
+ (host,) = hosts
+
+ result = host.call(run_bluetoothctl, f"show")
+ assert result.returncode == 0
+ assert f"Controller {host.bdaddr.upper()}" in result.stdout
+ assert "Powered: " in result.stdout
+ assert "Discoverable: no" in result.stdout
+
+
+@bluetoothd_reuse_config
+def test_bluetoothctl_list(hosts):
+ (host,) = hosts
+
+ result = host.call(run_bluetoothctl, "list")
+ assert result.returncode == 0
+ assert re.search(rf"{host.bdaddr.upper()}.*\[default\]", result.stdout)
+
+
+@bluetoothd_reuse_config
+def test_bluetoothctl_script_show(hosts):
+ (host,) = hosts
+
+ result = host.call(run_bluetoothctl_script, f"show")
+ assert result.returncode == 0
+ assert f"Controller {host.bdaddr.upper()}" in result.stdout
+ assert "Powered: " in result.stdout
+ assert "Discoverable: no" in result.stdout
+
+
+@bluetoothd_reuse_config
+def test_bluetoothctl_script_list(hosts):
+ (host,) = hosts
+
+ result = host.call(run_bluetoothctl_script, f"list")
+ assert result.returncode == 0
+ assert re.search(rf"{host.bdaddr.upper()}.*\[default\]", result.stdout)
diff --git a/test/functional/test_btmgmt_vm.py b/test/functional/test_btmgmt_vm.py
new file mode 100644
index 000000000..d51d32c6a
--- /dev/null
+++ b/test/functional/test_btmgmt_vm.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+Tests for btmgmt using VM instances
+"""
+import sys
+import pytest
+import subprocess
+import tempfile
+
+from pytest_bluez import host_config, find_exe, run
+
+pytestmark = [pytest.mark.vm]
+
+btmgmt = find_exe("tools", "btmgmt")
+
+
+@host_config([])
+def test_btmgmt_info(hosts):
+ (host,) = hosts
+
+ result = host.call(
+ run,
+ [btmgmt, "--index", "0", "info"],
+ stdout=subprocess.PIPE,
+ stdin=subprocess.DEVNULL,
+ encoding="utf-8",
+ )
+ assert result.returncode == 0
+ assert f"addr {host.bdaddr.upper()}" in result.stdout
diff --git a/test/pytest.ini b/test/pytest.ini
new file mode 100644
index 000000000..899871fa2
--- /dev/null
+++ b/test/pytest.ini
@@ -0,0 +1,13 @@
+[pytest]
+log_format = %(asctime)s %(levelname)-6s %(name)-20s: %(message)s
+log_date_format = %Y-%m-%d %H:%M:%S.%f
+log_level = 0
+log_file = test-functional.log
+markers =
+ vm: tests requiring VM image
+
+addopts =
+ -p pytest_bluez
+
+# Default timeout
+vm_timeout = 30
diff --git a/test/pytest_bluez/__init__.py b/test/pytest_bluez/__init__.py
new file mode 100644
index 000000000..cb4dca71c
--- /dev/null
+++ b/test/pytest_bluez/__init__.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from .rpc import RemoteError
+
+from .env import *
+from .utils import *
+from .host_plugins import *
+
+from .plugin import *
diff --git a/test/pytest_bluez/env.py b/test/pytest_bluez/env.py
new file mode 100644
index 000000000..cb920e714
--- /dev/null
+++ b/test/pytest_bluez/env.py
@@ -0,0 +1,784 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+Test environment:
+
+- one or more qemu instances running Linux kernel + BlueZ + other stack
+- connected by btvirt, or real USB Bluetooth controllers
+- Python RPC connection to each via unix socket <-> qemu chardev
+
+"""
+import sys
+import os
+import signal
+import re
+import pwd
+import time
+import shlex
+import argparse
+import shutil
+import threading
+import tempfile
+import operator
+import logging
+import socket
+import pickle
+import traceback
+import resource
+import warnings
+import subprocess
+from pathlib import Path
+from subprocess import Popen, DEVNULL, PIPE, run
+
+from gi.repository import GLib
+
+from . import rpc, utils
+
+__all__ = ["HostPlugin", "HostProxy", "PluginProxy"]
+
+log = logging.getLogger("env")
+
+
+class HostPlugin:
+ """
+ Plugin to insert code to VM host side.
+
+ Attributes:
+ name (str): unique name for the plugin
+ depends (tuple[HostPlugin]): plugins to be loaded before this one
+ value (object): object to appear as HostProxy attribute on parent side.
+ If None, the plugin is represented by a proxy object that does RPC
+ calls. Otherwise, must be a serializable value. If it is a subclass
+ of `PluginProxy`, the method `set_connection` is called after plugin
+ load.
+ """
+
+ name = None
+ depends = ()
+ value = None
+
+ def __init__(self):
+ """
+ Configure plugin (runs on parent host side). This is
+ called at test discovery time, so should mainly store static
+ data.
+
+ """
+ pass
+
+ def presetup(self, config):
+ """
+ Parent host-side setup, before VM environment is started. May
+ use pytest.skip() to skip tests in case plugin cannot be set up.
+
+ Args:
+ config (pytest.Config): pytest configuration object
+ """
+ pass
+
+ def setup(self, impl):
+ """
+ VM-side setup
+
+ Args:
+ impl (Implementation): plugin host object
+ """
+ pass
+
+ def teardown(self):
+ """VM-side teardown"""
+ pass
+
+
+class HostProxy:
+ """
+ Parent-side proxy for VM host: load plugins, RPC calls to plugins
+ """
+
+ def __init__(self, path, timeout, name):
+ self._path = path
+ self._active_conn = None
+ self._timeout = timeout
+ self._plugins = {}
+ self._name = name
+
+ def load(self, plugin: HostPlugin):
+ """
+ Load given plugin to the VM host synchronously.
+ """
+ self.start_load(plugin)
+ self.wait_load()
+
+ def set_instance_name(self, name):
+ self.instance_name = name
+ self._conn.call_noreply("set_instance_name", name)
+
+ def start_load(self, plugin: HostPlugin):
+ """
+ Initiate loading the given plugin to the VM host. Use
+ `wait_load` to wait for completion and make loaded plugins
+ usable.
+
+ """
+ if plugin.name in self._plugins:
+ # Already loaded
+ return
+ self._conn.call_noreply("start_load", plugin)
+ self._plugins[plugin.name] = None
+
+ def wait_load(self, timeout=None):
+ """
+ Wait for plugin loads to complete, and make plugins available.
+ """
+ for name, value in self._conn.call("wait_load", timeout=timeout).items():
+ if value is None:
+ value = PluginProxy()
+ if isinstance(value, PluginProxy):
+ value.set_connection(name, self._active_conn)
+ self._plugins[name] = value
+
+ @property
+ def _conn(self):
+ if self._active_conn is None:
+ self._active_conn = rpc.client_unix_socket(
+ self._path, timeout=self._timeout, name=self._name
+ )
+ return self._active_conn
+
+ def __getattr__(self, name):
+ if name not in self._plugins:
+ raise AttributeError(name)
+ return self._plugins[name]
+
+ def close(self):
+ """
+ Shutdown this VM host tester instance.
+ """
+ self._plugins = {}
+ if self._active_conn is not None:
+ self._active_conn.close()
+ self._active_conn = None
+
+ def _close_start(self):
+ self._plugins = {}
+ if self._active_conn is not None:
+ self._active_conn.close_start()
+
+ def _close_finish(self, force=False):
+ if self._active_conn is not None:
+ self._active_conn.close_finish(force=force)
+ self._active_conn = None
+
+
+class PluginProxy:
+ """
+ Host-side proxy for a plugin: RPC calls
+
+ Attributes:
+ _name (str): plugin name
+ _conn (rpc.Connection): RPC connection
+ """
+
+ def __init__(self):
+ self._name = None
+ self._conn = None
+
+ def set_connection(self, name, conn):
+ self._name = name
+ self._conn = conn
+
+ def __call__(self, *a, **kw):
+ return self._conn.call("call_plugin", self._name, "__call__", *a, **kw)
+
+ def __getattr__(self, name):
+ if name.startswith("_"):
+ raise AttributeError(name)
+ return lambda *a, **kw: self._conn.call(
+ "call_plugin", self._name, name, *a, **kw
+ )
+
+ def _call_noreply(self, name, *a, **kw):
+ self._conn.call_noreply("call_plugin", self._name, name, *a, **kw)
+
+
+class Implementation:
+ """
+ VM-side main instance: setup/teardown plugins, plugin RPC server side
+ """
+
+ def __init__(self):
+ self.plugins = {}
+ self.plugin_order = []
+ self.load_error = False
+
+ def set_instance_name(self, name):
+ self.instance_name = name
+ socket.sethostname(name)
+
+ def start_load(self, plugin):
+ try:
+ log.info(f"Plugin {plugin.name} load")
+ plugin.setup(self)
+ except:
+ self.load_error = True
+ raise
+ self.plugins[plugin.name] = plugin
+ self.plugin_order.append(plugin.name)
+ log.info(f"Plugin {plugin.name} ready")
+
+ def wait_load(self):
+ if self.load_error:
+ raise RuntimeError("load failed")
+ log.debug(f"Plugins ready")
+ return {p.name: getattr(p, "value", None) for p in self.plugins.values()}
+
+ def _unload(self, name):
+ self.plugin_order.remove(name)
+ p = self.plugins.pop(name)
+ method = getattr(p, "teardown", None)
+ if method is not None:
+ try:
+ method()
+ except BaseException as exc:
+ tb = traceback.format_exc()
+ log.error(f"Plugin {name} teardown error: {exc}\n{tb}")
+ return False
+ return True
+
+ def call_plugin(self, name, method, *a, **kw):
+ return getattr(self.plugins[name], method)(*a, **kw)
+
+ def teardown(self):
+ success = True
+ while self.plugin_order:
+ name = self.plugin_order[-1]
+ log.info(f"Plugin {name} teardown")
+ if not self._unload(name):
+ success = False
+ log.info(f"Plugin {name} teardown done")
+ if not success:
+ raise RuntimeError("teardown failure")
+
+
+def _find_vport(target_name):
+ """
+ Find RPC control virtio port
+ """
+ for port in Path("/sys/class/virtio-ports").iterdir():
+ with open(port / "name", "rb") as f:
+ name = f.read(64)
+ if name == target_name:
+ return f"/dev/{port.name}"
+
+ raise RuntimeError(f"No virtio port {target_name} found")
+
+
+class _RunnerLogHandler(logging.Handler):
+ def __init__(self, stream):
+ super().__init__()
+ self.stream = stream
+
+ def flush(self):
+ self.stream.flush()
+
+ def emit_simple(self, name, levelno, line, nsec):
+ self.stream.write(f"\x00{name}\x01{levelno}\x02{nsec}\x03{line}\n")
+
+ def emit(self, record):
+ try:
+ msg = record.getMessage()
+ if record.exc_info:
+ msg += "\n"
+ msg += traceback.format_exception(*record.exc_info)
+ name = record.name
+ levelno = record.levelno
+ nsec = getattr(record, "nsec", None)
+ if nsec is None:
+ nsec = int(record.created * 1e9)
+ for line in msg.splitlines():
+ self.emit_simple(name, levelno, msg, nsec)
+ self.stream.flush()
+ except RecursionError:
+ raise
+ except Exception:
+ self.handleError(record)
+
+ def start(self):
+ self.flush_thread = threading.Thread(target=self._flush_thread, daemon=True)
+ self.flush_thread.start()
+
+ def _flush_thread(self):
+ while True:
+ time.sleep(0.5)
+ self.stream.flush()
+
+
+def _main_runner_instance():
+ """
+ VM-side tester main instance
+ """
+ from dbus.mainloop.glib import DBusGMainLoop
+
+ # Start GLib mainloop early: dbus-python needs it
+ loop = GLib.MainLoop()
+ dbus_loop = DBusGMainLoop(set_as_default=True)
+ loop_thread = threading.Thread(target=loop.run, daemon=True)
+ loop_thread.start()
+
+ utils.SIMPLE_LOG_HANDLER.start()
+ try:
+ dev = _find_vport(b"bluez-func-test-rpc\n")
+ log.info(f"Test RPC server on {dev}")
+ rpc.server_file(dev, Implementation())
+ finally:
+ utils.SIMPLE_LOG_HANDLER.flush()
+ loop.quit()
+ log.info(f"Test RPC server quit")
+
+
+def _setup_vm_instance():
+ # Mount shared path
+ path = Path("/run/shared")
+ if not path.is_dir():
+ path.mkdir()
+ run(["mount", "-t", "9p", "/dev/shared", str(path)], check=True)
+
+ # Setup sys.path & defaults
+ with open("/run/shared/defaults", "rb") as f:
+ (sys.path, utils.SRC_DIR, utils.BUILD_DIR, utils.DEFAULT_TIMEOUT) = pickle.load(
+ f
+ )
+
+ # Set up core dumps
+ with open("/proc/sys/kernel/core_pattern", "w") as f:
+ f.write("|/usr/bin/env tee /run/shared/test-functional-%h-%e-%t.core")
+
+ resource.setrlimit(
+ resource.RLIMIT_CORE, (resource.RLIM_INFINITY, resource.RLIM_INFINITY)
+ )
+
+ # Set up default ASAN options
+ os.environ["ASAN_OPTIONS"] = (
+ "detect_leaks=1:leak_check_at_exit=0:print_summary=1:abort_on_error=1:"
+ "use_madv_dontdump=1:disable_coredump=0:unmap_shadow_on_exit=1:"
+ "strict_string_checks=1:detect_stack_use_after_return=1:"
+ "check_initialization_order=1:strict_init_order=1:"
+ "quarantine_size_mb=16"
+ )
+ os.environ["UBSAN_OPTIONS"] = "print_stacktrace=1:print_summary=1:abort_on_error=1"
+ os.environ["LSAN_OPTIONS"] = "exitcode=0:log_threads=1"
+
+ # Start (a)getty
+ try:
+ exe = utils.find_exe("", "agetty")
+ except FileNotFoundError:
+ log.warning("agetty not available")
+ exe = None
+ try:
+ setsid = utils.find_exe("", "setsid")
+ except FileNotFoundError:
+ log.warning("setsid not available")
+ setsid = None
+
+ if exe is not None and setsid is not None:
+ # Use shell script for restarting getty: uses less memory than Python
+ script = Path("/tmp/getty.script")
+ with open(script, "w") as f:
+ f.write(
+ f'#!/bin/sh\nwhile true; do "{setsid}" "{exe}" -n -h -L -l /bin/bash ttyS1; done'
+ )
+
+ os.chmod(script, 0o755)
+
+ pid = os.fork()
+ if pid == 0:
+ os.setsid()
+ os.execv(script, [script])
+ os._exit(1)
+
+ _start_chronyd()
+
+
+def _start_chronyd():
+ global _CHRONYD
+
+ try:
+ exe = utils.find_exe("", "chronyd")
+ except FileNotFoundError:
+ log.warning("chronyd not available")
+ return
+
+ if not Path("/dev/ptp0").exists():
+ log.warning("/dev/ptp0 not available")
+ return
+
+ tmpdir = tempfile.mkdtemp(prefix=f"chronyd-")
+ config = Path(tmpdir) / "chronyd.conf"
+
+ with open(config, "w") as f:
+ text = f"makestep 0.1 3\nrefclock PHC /dev/ptp0 poll -2\n"
+ f.write(text)
+
+ cmd = [exe, "-n", "-f", str(config), "-q"]
+ log.debug("Synchronizing clock: {}".format(utils.quoted(cmd)))
+ subprocess.run(cmd, check=True)
+
+ cmd = [exe, "-n", "-f", str(config)]
+ log.debug("Starting chronyd: {}".format(utils.quoted(cmd)))
+ _CHRONYD = subprocess.Popen(cmd)
+
+
+def _reset_vm_instance():
+ # Power cycle controller to reset it between tests
+ btmgmt = utils.find_exe("tools", "btmgmt")
+ run([btmgmt, "power", "off"], check=True)
+ run([btmgmt, "power", "on"], check=True)
+
+
+def _main_runner():
+ """
+ VM-side tester supervisor
+ """
+ log_port = _find_vport(b"bluez-func-test-log\n")
+ log_stream = open(log_port, "w", encoding="utf-8", errors="surrogateescape")
+ utils.SIMPLE_LOG_HANDLER = _RunnerLogHandler(log_stream)
+ logging.basicConfig(level=0, handlers=[utils.SIMPLE_LOG_HANDLER])
+
+ # Basic VM setup
+ _setup_vm_instance()
+
+ # Preload libraries
+ import dbus
+ import pexpect
+
+ # Keep one instance running
+ while True:
+ log.info("Starting test instance")
+
+ _reset_vm_instance()
+
+ pid = os.fork()
+ if pid == 0:
+ os.setpgid(0, 0)
+ _main_runner_instance()
+ os._exit(0)
+ else:
+ try:
+ os.waitpid(pid, 0)
+ except ChildProcessError:
+ pass
+
+ log.info("Terminating test instance")
+
+ done = False
+
+ while not done:
+ for sig in [signal.SIGTERM, signal.SIGCONT, signal.SIGKILL]:
+ try:
+ os.kill(-pid, sig)
+ time.sleep(0.5 if sig == signal.SIGCONT else 0.1)
+ except ProcessLookupError:
+ done = True
+ break
+
+
+ENV_INDEX = -1
+
+
+class Environment:
+ def __init__(self, kernel, num_hosts, usb_indices=None, timeout=20, mem=None):
+ if Path(kernel).is_dir():
+ self.kernel = str(Path(kernel) / "arch" / "x86" / "boot" / "bzImage")
+ else:
+ self.kernel = str(kernel)
+
+ self.num_hosts = operator.index(num_hosts)
+ self.jobs = []
+ self.log_streams = []
+ self.hosts = []
+ self.timeout = float(timeout)
+ self.path = None
+ self.reuse_group = None
+ self.mem = mem
+
+ if usb_indices is None:
+ self.usb_indices = None
+ elif usb_indices is not None and self.num_hosts <= len(usb_indices):
+ self.usb_indices = tuple(usb_indices)
+ else:
+ raise ValueError(
+ "USB redirection enabled, but not enough controllers for each host"
+ )
+
+ if sys.version_info >= (3, 12):
+ self.runner = [sys.executable, "-P"]
+ else:
+ self.runner = [sys.executable]
+ self.runner += [str((Path(__file__).parent / "runner.py").absolute())]
+
+ def start(self):
+ self.path = Path(tempfile.mkdtemp(prefix="bluez-func-test-"))
+
+ if self.usb_indices is None:
+ args = self._start_btvirt()
+ else:
+ args = self._start_usb()
+
+ paths, names = self._start_runners(args)
+ self._start_hosts(paths, names)
+
+ def stop(self):
+ for job in self.jobs:
+ if job.poll() is not None:
+ continue
+ job.terminate()
+
+ while self.jobs:
+ job = self.jobs.pop()
+ if job.poll() is None:
+ job.wait()
+
+ while self.log_streams:
+ self.log_streams.pop().close()
+
+ while self.hosts:
+ try:
+ self.hosts.pop().close()
+ except rpc.RemoteError:
+ # error already logged
+ pass
+
+ # Clean up tmpdir (btvirt, own sockets, rmdir)
+ if self.path is not None:
+ for f in list(self.path.iterdir()):
+ if f.name.startswith("bt-server-"):
+ f.unlink()
+ if f.name.startswith("shared-"):
+ shutil.rmtree(f.resolve(), ignore_errors=True)
+ continue
+ if f.name.startswith("bluez-func-test-"):
+ f.unlink()
+
+ self.path.rmdir()
+ self.path = None
+
+ def close_hosts(self):
+ try:
+ for h in self.hosts:
+ h._close_start()
+ finally:
+ errors = []
+ for h in self.hosts:
+ try:
+ h._close_finish(force=bool(errors))
+ except Exception as exc:
+ tb = "\n ".join(traceback.format_exc().split("\n"))
+ errors.append(
+ f"{exc}\nError closing {h._name}: Traceback:\n {tb}"
+ )
+
+ if errors:
+ errors = "\n".join(errors)
+ raise RuntimeError(f"Errors closing hosts:\n{errors}")
+
+ def _add_log(self, *a, **kw):
+ f = utils.LogStream(*a, **kw)
+ self.log_streams.append(f)
+ return f.stream
+
+ def _start_btvirt(self):
+ exe = utils.find_exe("emulator", "btvirt")
+ logger = self._add_log("btvirt")
+
+ cmd = [exe, f"--server={self.path}"]
+ log.info("Starting btvirt: {}".format(utils.quoted(cmd)))
+
+ job = Popen(
+ cmd,
+ stdout=logger,
+ stderr=logger,
+ stdin=DEVNULL,
+ )
+ self.jobs.append(job)
+
+ socket = self.path / "bt-server-bredrle"
+ utils.wait_files([job], [socket])
+ return [[f"-u{socket}"]] * self.num_hosts
+
+ @classmethod
+ def check_controller(cls, name):
+ subsys = Path("/sys/class/bluetooth") / name / "device" / "subsystem"
+ if subsys.resolve() != Path("/sys/bus/usb"):
+ raise ValueError(f"{devname} is not an USB device")
+
+ devpath = Path(f"/sys/class/bluetooth/{name}/device/../")
+ with open(devpath / "busnum", "r") as f:
+ busnum = "{:03}".format(int(f.read().strip()))
+ with open(devpath / "devnum", "r") as f:
+ devnum = "{:03}".format(int(f.read().strip()))
+
+ devname = f"/dev/bus/usb/{busnum}/{devnum}"
+ if not Path(devname).exists():
+ raise ValueError(f"{devname} does not exist")
+
+ try:
+ with open(devname, "wb") as f:
+ pass
+ except IOError:
+ user = pwd.getpwuid(os.getuid()).pw_name.strip()
+ message = (
+ f"error: cannot open {devname} for {name} USB redirection. "
+ f"Run: 'sudo setfacl -m user:{user}:rw- {devname}' "
+ f"to grant the permission"
+ )
+ raise ValueError(message)
+
+ return busnum, devnum
+
+ def _start_usb(self):
+ args = []
+
+ for index in self.usb_indices[: self.num_hosts]:
+ busnum, devnum = self.check_controller(index)
+ args.append(["-U", f"usb-host,hostbus={busnum},hostaddr={devnum}"])
+
+ return args
+
+ def _start_runners(self, args):
+ global ENV_INDEX
+
+ test_runner = utils.find_exe("tools", "test-runner")
+
+ socket_paths = []
+ host_names = []
+
+ ENV_INDEX += 1
+
+ for idx, arg in enumerate(args):
+ socket_path = str(self.path / f"bluez-func-test-rpc-{idx}")
+ socket_paths.append(socket_path)
+
+ tty_path = str(self.path / f"bluez-func-test-tty-{idx}")
+ log_path = str(self.path / f"bluez-func-test-log-{idx}")
+
+ shared_path = self.path / f"shared-{idx}"
+ shared_path.mkdir()
+
+ # Python import paths & timeout
+ with open(shared_path / "defaults", "wb") as f:
+ pickle.dump(
+ (
+ sys.path,
+ utils.SRC_DIR,
+ utils.BUILD_DIR,
+ utils.DEFAULT_TIMEOUT * 3 / 4,
+ ),
+ f,
+ )
+
+ # RPC socket
+ qemu_args = [
+ "-chardev",
+ f"socket,id=ser0,path={socket_path},server=on,wait=off",
+ ]
+
+ qemu_args += [
+ "-device",
+ "virtio-serial",
+ "-device",
+ "virtserialport,chardev=ser0,name=bluez-func-test-rpc",
+ ]
+
+ # Separate TTY access
+ qemu_args += [
+ "-chardev",
+ f"socket,id=ser1,path={tty_path},server=on,wait=off",
+ ]
+
+ qemu_args += [
+ "-device",
+ "pci-serial,chardev=ser1",
+ ]
+
+ # Log socket
+ qemu_args += [
+ "-chardev",
+ f"socket,id=ser2,path={log_path},server=on,wait=on",
+ ]
+
+ qemu_args += [
+ "-device",
+ "virtio-serial",
+ "-device",
+ "virtserialport,chardev=ser2,name=bluez-func-test-log",
+ ]
+
+ # Shared filesystem
+ qemu_args += [
+ "-fsdev",
+ f"local,id=fsdev-shared,path={shared_path},readonly=off,security_model=none,multidevs=remap",
+ "-device",
+ "virtio-9p-pci,fsdev=fsdev-shared,mount_tag=/dev/shared",
+ ]
+
+ if self.mem:
+ qemu_args += ["-m", str(self.mem)]
+
+ extra_args = []
+ for q in qemu_args:
+ extra_args += ["-o", q]
+
+ extra_args += ["-H"]
+
+ cmd = (
+ [test_runner, f"--kernel={self.kernel}"]
+ + arg
+ + extra_args
+ + ["--"]
+ + self.runner
+ )
+
+ log.info("Starting host: {}".format(utils.quoted(cmd)))
+ log.info(f"TTY: socat {tty_path} STDIO,rawer")
+
+ host_names.append(f"host.{ENV_INDEX}.{idx}")
+
+ logger = self._add_log(host_names[-1])
+ self.jobs.append(Popen(cmd, stdout=logger, stderr=logger, stdin=DEVNULL))
+
+ # Start log reader
+ utils.wait_files(self.jobs, [log_path])
+
+ log_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ log_sock.connect(log_path)
+ self._add_log(
+ host_names[-1],
+ pattern=".*\x00([^\x00-\x03]+)\x01([^\x00-\x03]+)\x02([^\x00-\x03]+)\x03",
+ stream=log_sock,
+ )
+
+ utils.wait_files(self.jobs, socket_paths)
+
+ return socket_paths, host_names
+
+ def _start_hosts(self, socket_paths, host_names):
+ if len(socket_paths) != self.num_hosts:
+ raise RuntimeError("Wrong number of sockets")
+
+ for path, name in zip(socket_paths, host_names):
+ host = HostProxy(path, timeout=self.timeout, name=name)
+ self.hosts.append(host)
+
+ def __del__(self):
+ self.stop()
+
+ def __enter__(self):
+ try:
+ self.start()
+ except:
+ self.stop()
+ raise
+ return self
+
+ def __exit__(self, type, value, tb):
+ self.stop()
diff --git a/test/pytest_bluez/host_plugins.py b/test/pytest_bluez/host_plugins.py
new file mode 100644
index 000000000..3b9cf7857
--- /dev/null
+++ b/test/pytest_bluez/host_plugins.py
@@ -0,0 +1,566 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+VM host plugins
+"""
+import os
+import sys
+import subprocess
+import collections
+import logging
+import tempfile
+import time
+import shutil
+import queue
+import signal
+import functools
+import threading
+import resource
+from pathlib import Path
+
+import pytest
+import pexpect
+import dbus
+from gi.repository import GLib
+
+from . import env, utils
+
+__all__ = [
+ "host_config",
+ "parametrized_host_config",
+ "Bdaddr",
+ "Bluetoothctl",
+ "Bluetoothd",
+ "Call",
+ "DbusSession",
+ "DbusSystem",
+ "Pexpect",
+ "Rcvbuf",
+]
+
+
+class Bdaddr(env.HostPlugin):
+ """
+ Host plugin providing `host.bdaddr`. Loaded by default.
+ """
+
+ name = "bdaddr"
+
+ def setup(self, impl):
+ self.value = utils.get_bdaddr()
+
+
+class Rcvbuf(env.HostPlugin):
+ """
+ Host plugin setting pipe buffer size defaults. Loaded by default.
+ """
+
+ name = "rcvbuf"
+
+ def __init__(self, rcvbuf=None):
+ self.rcvbuf = rcvbuf
+
+ def presetup(self, config):
+ if self.rcvbuf is None:
+ self.rcvbuf = config.getini("host_plugins.rcvbuf.default")
+
+ self.rcvbuf = int(self.rcvbuf)
+
+ def setup(self, impl):
+ self.log = logging.getLogger(self.name)
+
+ self.log.info(f"Set SO_RCVBUF default = {self.rcvbuf}")
+ with open("/proc/sys/net/core/rmem_default", "wb") as f:
+ f.write(f"{self.rcvbuf}".encode("ascii"))
+
+
+class Call(env.HostPlugin):
+ """
+ Host plugin providing ``host.call(func, *args, **kw)`` and `call_async`
+ which invoke the given functions on VM host side. Loaded by default.
+
+ Example:
+
+ result = host0.call(my_func, 1, 2, 3)
+
+ Example:
+
+ result_async = host0.call(my_func, 1, 2, 3, sync=False)
+ ...
+ result = result_async.wait()
+ """
+
+ name = "call"
+
+ def setup(self, impl):
+ self._results = {}
+ self._id = 0
+ self.value = self.Proxy()
+
+ def __call__(self, func, *a, **kw):
+ return func(*a, **kw)
+
+ def call_async(self, func, *a, **kw):
+ value = None
+ try:
+ value = func(*a, **kw)
+ except BaseException as exc:
+ value = exc
+ raise
+ finally:
+ self._id += 1
+ self._results[self._id] = value
+
+ def wait_async(self, id_value):
+ return self._results.pop(id_value)
+
+ class Proxy(env.PluginProxy):
+ def __init__(self):
+ self._id = 0
+
+ def __call__(self, func, *a, **kw):
+ if kw.pop("sync", True):
+ return self._conn.call(
+ "call_plugin", self._name, "__call__", func, *a, **kw
+ )
+ else:
+ self._conn.call_noreply(
+ "call_plugin", self._name, "call_async", func, *a, **kw
+ )
+ self._id += 1
+ return Call.ResultProxy(self, self._id)
+
+ class ResultProxy:
+ def __init__(self, plugin, id_value):
+ self.plugin = plugin
+ self.id_value = id_value
+
+ def wait(self):
+ return self.plugin.wait_async(self.id_value)
+
+
+class _Dbus(env.HostPlugin):
+ def __init__(self):
+ self.exe = utils.find_exe("", "dbus-daemon")
+
+ def setup(self, impl):
+ self.log = logging.getLogger(self.name)
+ self.log_stream = utils.LogStream(self.name)
+
+ self.tmpdir = utils.TmpDir(prefix=f"{self.name}-")
+ self.config = Path(self.tmpdir.name) / "config.xml"
+
+ socket = f"/run/dbus-{self.dbus_type}.socket"
+ self.address = "unix:path={}".format(socket)
+
+ # Have to set both, dbus-python needs both early
+ os.environ["DBUS_SYSTEM_BUS_ADDRESS"] = "unix:path=/run/dbus-system.socket"
+ os.environ["DBUS_SESSION_BUS_ADDRESS"] = "unix:path=/run/dbus-session.socket"
+
+ with open(self.config, "w") as f:
+ text = f"""
+ <!DOCTYPE busconfig PUBLIC
+ "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+ <busconfig>
+ <type>{self.dbus_type}</type>
+ <listen>{self.address}</listen>
+ <policy context="default">
+ <allow user="*"/>
+ <allow own="*"/>
+ <allow send_type="method_call"/>
+ <allow send_type="signal"/>
+ <allow send_type="method_return"/>
+ <allow send_type="error"/>
+ <allow receive_type="method_call"/>
+ <allow receive_type="signal"/>
+ <allow receive_type="method_return"/>
+ <allow receive_type="error"/>
+ </policy>
+ <limit name="reply_timeout">{round(utils.DEFAULT_TIMEOUT * 1000)}</limit>
+ </busconfig>
+ """
+ f.write(text)
+
+ cmd = [
+ self.exe,
+ "--nofork",
+ "--nopidfile",
+ "--nosyslog",
+ f"--config-file={self.config}",
+ ]
+
+ self.log.debug(
+ "Starting {} @ {}: {}".format(self.name, self.address, utils.quoted(cmd))
+ )
+
+ self.job = subprocess.Popen(
+ cmd,
+ stdout=self.log_stream.stream,
+ stderr=subprocess.STDOUT,
+ )
+ utils.wait_files([self.job], [socket])
+ self.log.debug(f"{self.name} ready")
+
+ def teardown(self):
+ self.job.terminate()
+ self.tmpdir.cleanup()
+ self.log_stream.close()
+
+
+class DbusSystem(_Dbus):
+ """
+ Host plugin providing system DBus, at address
+ `impl.plugins["dbus-system"].address`.
+
+ Warning:
+ dbus-python **MUST** be used only from the GLib main loop,
+ as the library has concurrency bugs. All functions using it
+ **MUST** either run from GLib main loop eg. via mainloop_wrap
+ """
+
+ name = "dbus-system"
+ dbus_type = "system"
+
+
+class DbusSession(_Dbus):
+ """
+ Host plugin providing system DBus, at address
+ `impl.plugins["dbus-session"].address`.
+
+ Warning:
+ dbus-python **MUST** be used only from the GLib main loop,
+ as the library has concurrency bugs. All functions using it
+ **MUST** either run from GLib main loop eg. via mainloop_wrap
+ """
+
+ name = "dbus-session"
+ dbus_type = "session"
+
+
+class Bluetoothd(env.HostPlugin):
+ """
+ Host plugin starting Bluetoothd.
+ """
+
+ name = "bluetoothd"
+ depends = [DbusSystem()]
+
+ def __init__(self, debug=True, conf=None, args=()):
+ super().__init__()
+
+ self.conf = conf
+ self.args = tuple(args)
+ if debug and "-d" not in self.args:
+ self.args += ("-d",)
+
+ @utils.mainloop_wrap
+ def setup(self, impl):
+ self.log = logging.getLogger(self.name)
+
+ exe = utils.find_exe("src", "bluetoothd")
+
+ self.tmpdir = utils.TmpDir(prefix="bluetoothd-state-")
+ state_dir = Path(self.tmpdir.name) / "state"
+ conf = Path(self.tmpdir.name) / "main.conf"
+
+ state_dir.mkdir()
+
+ if self.conf is None:
+ with open(str(conf), "w") as f:
+ pass
+ else:
+ with open(str(conf), "w") as f:
+ f.write(self.conf)
+
+ envvars = dict(os.environ)
+ envvars["STATE_DIRECTORY"] = str(state_dir)
+
+ cmd = [exe, "--nodetach", "-f", str(conf)] + list(self.args)
+
+ self.log.info("Start bluetoothd: {}".format(utils.quoted(cmd)))
+
+ self.log_stream = utils.LogStream("bluetoothd")
+ self.job = subprocess.Popen(
+ cmd,
+ env=envvars,
+ stdin=subprocess.DEVNULL,
+ stdout=self.log_stream.stream,
+ stderr=subprocess.STDOUT,
+ )
+
+ # Wait for the adapter to appear powered
+ self.log.info("Wait for bluetoothd...")
+ bus = dbus.SystemBus()
+ bus.set_exit_on_disconnect(False)
+
+ def cond():
+ try:
+ adapter = dbus.Interface(
+ bus.get_object("org.bluez", "/org/bluez/hci0"),
+ "org.freedesktop.DBus.Properties",
+ )
+ if adapter.Get("org.bluez.Adapter1", "Powered"):
+ return True
+ except dbus.DBusException:
+ return False
+
+ utils.wait_until(cond)
+
+ self.log.info("Bluetoothd ready")
+
+ def teardown(self):
+ self.log.info("Stop bluetoothd")
+ self.job.terminate()
+ self.tmpdir.cleanup()
+ self.log_stream.close()
+
+
+class Pexpect(env.HostPlugin):
+ """
+ Host plugin for starting and controlling processes with pexpect.
+
+ Example:
+
+ btmgmt = host0.pexpect.spawn(find_exe("tools", "btmgmt"))
+ btmgmt.send("info\n")
+ btmgmt.expect("hci0")
+ btmgmt.close()
+ """
+
+ name = "pexpect"
+ depends = []
+
+ def setup(self, impl):
+ self.ctls = {}
+ self.ctl_id = 0
+ self.log = logging.getLogger(self.name)
+
+ self.log_stream = utils.LogStream(self.name)
+ self.value = self.Proxy()
+
+ def spawn(self, cmd):
+ from pexpect.popen_spawn import PopenSpawn
+
+ self.log.info("Spawn {}".format(utils.quoted(cmd)))
+
+ ctl = pexpect.popen_spawn.PopenSpawn(
+ cmd,
+ logfile=self.log_stream.stream,
+ timeout=utils.DEFAULT_TIMEOUT,
+ )
+ self.ctl_id += 1
+ self.ctls[self.ctl_id] = ctl
+ return self.ctl_id
+
+ def teardown(self):
+ for ctl in self.ctls.values():
+ ctl.sendeof()
+ ctl.kill(signal.SIGTERM)
+ self.log_stream.close()
+
+ def close(self, ctl_id):
+ ctl = self.ctls[ctl_id]
+ ctl.sendeof()
+ ctl.kill(signal.SIGTERM)
+ del self.ctls[ctl_id]
+
+ def expect(self, ctl_id, *a, **kw):
+ ctl = self.ctls[ctl_id]
+ ret = ctl.expect(*a, **kw)
+ self.log.debug("match found")
+ return ret, ctl.match.groups()
+
+ def send(self, ctl_id, *a, **kw):
+ ctl = self.ctls[ctl_id]
+ return ctl.send(*a, **kw)
+
+ class Proxy(env.PluginProxy):
+ def spawn(self, cmd):
+ ctl_id = self._conn.call("call_plugin", self._name, "spawn", cmd)
+ return Pexpect.CtlProxy(self, ctl_id)
+
+ class CtlProxy:
+ def __init__(self, plugin, ctl_id):
+ self._plugin = plugin
+ self.ctl_id = ctl_id
+
+ def __getattr__(self, name):
+ method = getattr(self._plugin, name)
+ return lambda *a, **kw: method(self.ctl_id, *a, **kw)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, type, value, tb):
+ self.close()
+
+
+class Bluetoothctl(env.HostPlugin):
+ """
+ Host plugin for starting and controlling `bluetoothctl` with pexpect.
+ """
+
+ name = "bluetoothctl"
+ depends = [Bluetoothd()]
+
+ def __init__(self):
+ self.exe = utils.find_exe("client", "bluetoothctl")
+
+ def setup(self, impl):
+ from pexpect.popen_spawn import PopenSpawn
+
+ self.log = logging.getLogger(self.name)
+ self.log_stream = utils.LogStream(self.name)
+
+ # Note: pexpect.spawn doesn't work under load: using a PTY
+ # appears to cause some messages be not received by
+ # bluetoothctl
+ self.ctl = pexpect.popen_spawn.PopenSpawn(
+ self.exe, logfile=self.log_stream.stream, timeout=utils.DEFAULT_TIMEOUT
+ )
+
+ def teardown(self):
+ self.ctl.sendeof()
+ self.ctl.kill(signal.SIGTERM)
+ self.log_stream.close()
+
+ def expect(self, *a, **kw):
+ ret = self.ctl.expect(*a, **kw)
+ self.log.debug("match found")
+ return ret, self.ctl.match.groups()
+
+ def send(self, *a, **kw):
+ return self.ctl.send(*a, **kw)
+
+
+HOST_SETUPS = 0
+DEFAULT_PLUGINS = [Rcvbuf(), Bdaddr(), Call()]
+
+
+def _expand_plugins(plugins):
+ """
+ Resolve plugin dependencies to linear load order
+ """
+ plugins = DEFAULT_PLUGINS + list(plugins)
+ to_load = []
+ seen = set()
+
+ while plugins:
+ deps = []
+ for dep in plugins[0].depends or ():
+ if type(dep) not in seen:
+ deps.append(dep)
+ seen.add(type(dep))
+ continue
+
+ if deps:
+ plugins = deps + plugins
+ continue
+
+ to_load.append(plugins.pop(0))
+
+ return tuple(to_load)
+
+
+def parametrized_host_config(
+ param_host_setups, hw=False, mem=None, ids=None, reuse=False
+):
+ """
+ Declare parametrized host configurations.
+
+ See https://docs.pytest.org/en/stable/how-to/parametrize.html for the
+ concept.
+
+ Args:
+ param_host_setups (list): list of host setups
+ hw (bool): whether to require hardware BT controller
+ reuse (bool): whether to define a setup where the test host processes
+ are not required to be torn down between tests. This is only useful
+ for tests that do not perturb e.g. bluetoothd state too much.
+
+ Returns:
+ callable: decorator setting pytest attributes
+ """
+ global HOST_SETUPS
+
+ host_setups = []
+ host_ids = []
+
+ if ids is not None:
+ if len(ids) != len(param_host_setups):
+ raise ValueError("Wrong number of ids")
+ host_ids = list(ids)
+
+ num_hosts = set(len(setup) for setup in param_host_setups)
+ if len(num_hosts) > 1:
+ raise ValueError("Parametrized host setups must have same host count")
+ num_hosts = num_hosts.pop()
+
+ for host_setup in param_host_setups:
+ setup = tuple(_expand_plugins(plugins) for plugins in host_setup)
+
+ name = f"hosts{HOST_SETUPS}"
+ HOST_SETUPS += 1
+
+ host_setup = dict(setup=setup, name=name, reuse=bool(reuse))
+ host_setups.append(host_setup)
+
+ if ids is None:
+ host_ids.append(name)
+
+ vm_setup = dict(num_hosts=num_hosts, hw=hw, mem=str(mem) if mem else "")
+ vm_ids = [
+ "vm{}{}{}".format(len(setup), f"-{mem}" if mem else "", "-hw" if hw else "")
+ ]
+
+ def decorator(func):
+ func = pytest.mark.parametrize(
+ "host_setup", host_setups, indirect=True, ids=host_ids
+ )(func)
+ func = pytest.mark.parametrize(
+ "vm_setup", [vm_setup], indirect=True, ids=vm_ids
+ )(func)
+ return func
+
+ return decorator
+
+
+def host_config(*host_setup, hw=False, mem=None, reuse=False):
+ """
+ Declare host configuration.
+
+ Args:
+ *host_setup: each argument is a list of plugins to be loaded on a host.
+ The number of arguments specifies the number of hosts.
+ hw (bool): whether to require hardware BT controller
+ mem (str): amount of memory for the VM instances
+ reuse (bool): whether to define a setup where the test host processes
+ are not required to be torn down between tests. This is only useful
+ for tests that do not perturb e.g. bluetoothd state too much.
+
+ Returns:
+ callable: decorator setting pytest attributes
+
+ Example:
+
+ @host_config([Bluetoothd()], [Bluetoothd()])
+ def test_something(hosts):
+ host0, host1 = hosts
+
+ Example:
+
+ # Allow not restarting Bluetoothd between tests sharing this configuration
+ base_config = host_config([Bluetoothd()], reuse=True)
+
+ @base_config
+ def test_one(hosts):
+ host0, = hosts
+
+ @base_config
+ def test_two(hosts):
+ # Note: uses same Bluetoothd() instance as above
+ host0, = hosts
+
+ """
+ return parametrized_host_config([host_setup], hw=hw, mem=mem, reuse=reuse)
diff --git a/test/pytest_bluez/plugin.py b/test/pytest_bluez/plugin.py
new file mode 100644
index 000000000..a713b2f90
--- /dev/null
+++ b/test/pytest_bluez/plugin.py
@@ -0,0 +1,559 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+import os
+import re
+import shutil
+import logging
+import warnings
+import traceback
+from pathlib import Path
+
+import pytest
+
+from . import utils, env
+
+
+__all__ = [
+ # hooks:
+ "pytest_addoption",
+ "pytest_configure",
+ "pytest_collectreport",
+ "pytest_collection_finish",
+ "pytest_collection_modifyitems",
+ "pytest_sessionstart",
+ "pytest_sessionfinish",
+ "pytest_runtest_logstart",
+ "pytest_runtest_setup",
+ "pytest_runtest_call",
+ "pytest_runtest_teardown",
+ "pytest_report_teststatus",
+ "pytest_runtest_logfinish",
+ # fixtures:
+ "kernel",
+ "usb_indices",
+ "host_setup",
+ "vm_setup",
+ "vm",
+ "hosts",
+ "vm_once",
+ "hosts_once",
+]
+
+# For logging test status messages to test-functional.log
+status_log = logging.getLogger("pytest")
+status_log_seen = set()
+
+
+def pytest_addoption(parser):
+ group = parser.getgroup("pytest_bluez", "bluez test options")
+
+ group.addoption(
+ "--kernel",
+ action="store",
+ default=None,
+ help=("Kernel image to use"),
+ )
+ group.addoption(
+ "--usb",
+ action="store",
+ default=None,
+ help=("USB HCI devices to use, e.g. 'hci0,hci1'"),
+ )
+ group.addoption(
+ "--force-usb",
+ action="store_true",
+ default=None,
+ help=("Force tests to run with USB controllers instead of btvirt"),
+ )
+ group.addoption(
+ "--bluez-build-dir",
+ action="store",
+ default=None,
+ type=Path,
+ help=("Build directory to find BlueZ development binaries"),
+ )
+ group.addoption(
+ "--bluez-src-dir",
+ action="store",
+ default=None,
+ type=Path,
+ help=("Directory to find BlueZ sources"),
+ )
+ group.addoption(
+ "--list",
+ action="store_true",
+ default=None,
+ help=("List tests"),
+ )
+ group.addoption(
+ "--log-filter",
+ action="append",
+ default=None,
+ help=(
+ "Enable/disable loggers by name. Can be passed multiple times. Example: +host.0,-rpc"
+ ),
+ )
+ group.addoption(
+ "--no-log-reorder",
+ action="store_true",
+ default=False,
+ help="Don't reorder logs to timestamp order",
+ )
+
+ group.addoption(
+ "--vm-timeout",
+ action="store",
+ default=None,
+ type=float,
+ help="Timeout in seconds for waiting for RPC reply with VM (default: 30 s)",
+ )
+ parser.addini(
+ "vm_timeout", "Default timeout for communication with VM etc.", default="30"
+ )
+
+ # host_plugins.Rcvbuf:
+ parser.addini(
+ "host_plugins.rcvbuf.default",
+ "Set default SO_RCVBUF (/proc/sys/net/core/rmem_default) on hosts",
+ default="1048576",
+ )
+
+
+def pytest_configure(config):
+ if config.option.list:
+ config.option.reportchars = "A"
+ config.option.no_header = True
+ config.option.verbose = -2
+
+ if config.option.bluez_build_dir is not None:
+ utils.BUILD_DIR = config.option.bluez_build_dir.absolute()
+ if config.option.bluez_src_dir is not None:
+ utils.SRC_DIR = config.option.bluez_src_dir.absolute()
+
+ utils.DEFAULT_TIMEOUT = config.option.vm_timeout or float(
+ config.getini("vm_timeout")
+ )
+
+ worker_id = os.environ.get("PYTEST_XDIST_WORKER")
+ logfile = config.getini("log_file")
+ if worker_id is not None and logfile:
+ logfile = logfile.replace(".log", f"-{worker_id}.log")
+ with open(logfile, "wb"):
+ pass
+
+ logging.basicConfig(
+ format=config.getini("log_format"),
+ filename=logfile,
+ level=config.getini("log_file_level"),
+ )
+
+
+COLLECT_ERRORS = []
+
+
+def pytest_collectreport(report):
+ if report.outcome != "passed":
+ COLLECT_ERRORS.append((report.outcome, report.fspath))
+
+
+def pytest_collection_finish(session):
+ if session.config.option.list:
+ cwd = Path(".").resolve()
+ root = session.config.rootpath.absolute()
+
+ regex = re.compile(r"\[.*")
+ names = set(
+ (root.joinpath(item.location[0]), regex.sub("", item.location[2]))
+ for item in session.items
+ )
+
+ for path, name in sorted(names):
+ print(f"{path.resolve().relative_to(cwd, walk_up=True)}::{name}")
+ for outcome, name in COLLECT_ERRORS:
+ print(f"{outcome.upper()} {name}")
+ print()
+ os._exit(0)
+
+
+def _get_item_vm_host_setup(item):
+ callspec = getattr(item, "callspec", None)
+ if callspec is not None:
+ return (
+ callspec.params.get("vm_setup", None),
+ callspec.params.get("host_setup", None),
+ )
+ return None, None
+
+
+@pytest.hookimpl(tryfirst=True)
+def pytest_collection_modifyitems(session, config, items):
+ # Sort VM-using tests to minimize VM setup/teardown
+ def sort_key(item):
+ vm_setup, host_setup = _get_item_vm_host_setup(item)
+ key = ()
+ if vm_setup:
+ key += tuple(sorted(vm_setup.items()))
+ if host_setup:
+ key += (host_setup["name"],)
+ return key
+
+ if not config.option.list:
+ items.sort(key=sort_key)
+
+ # Specify default groups for pytest-xdist --dist loadgroup
+ if config.pluginmanager.has_plugin("xdist"):
+ for item in items:
+ if item.get_closest_marker("xdist_group") is not None:
+ continue
+
+ _, host_setup = _get_item_vm_host_setup(item)
+ if not host_setup or not host_setup["reuse"]:
+ continue
+
+ xdist_group = "reuse-{}".format(host_setup["name"])
+ item.add_marker(pytest.mark.xdist_group(xdist_group))
+
+
+#
+# Logging customization:
+#
+# - pattern-based log filtering
+# - log entry reordering to timestamp order
+# - logging test stages and outcomes to test log file
+#
+
+
+def pytest_sessionstart(session):
+ _enable_log_filters(session.config)
+
+
+def _enable_log_filters(config, handlers=None):
+ if handlers is None:
+ handlers = logging.root.handlers
+
+ allow = set()
+ deny = set()
+
+ if config.option.log_filter is not None:
+ for item in config.option.log_filter:
+ for name in item.split(","):
+ if name.startswith("+"):
+ allow.add(name[1:])
+ elif name.startswith("-"):
+ deny.add(name[1:])
+ else:
+ allow.add(name)
+
+ utils.LogNameFilter.enable(handlers, allow, deny)
+
+ if not config.option.no_log_reorder:
+ utils.LogReorderFilter.enable(handlers)
+
+ for handler in handlers:
+ fmt = getattr(handler, "formatter", None)
+ if hasattr(fmt, "add_color_level"):
+ fmt.add_color_level(utils.OUT, "white")
+
+
+def pytest_sessionfinish(session):
+ utils.LogNameFilter.disable(logging.root.handlers)
+ utils.LogReorderFilter.disable(logging.root.handlers)
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_logstart(nodeid, location):
+ utils.LogReorderFilter.flush_all()
+ yield
+
+
+def status_log_stage(name, stage):
+ status_log.info(f"\n\n==== {name}: {stage} ====")
+
+ try:
+ yield
+ except:
+ utils.LogReorderFilter.flush_all()
+ raise
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_setup(item):
+ _enable_log_filters(item.session.config, logging.root.handlers[-1:])
+ yield from status_log_stage(item.nodeid, "setup")
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_call(item):
+ yield from status_log_stage(item.nodeid, "call")
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_teardown(item, nextitem):
+ yield from status_log_stage(item.nodeid, "teardown")
+ utils.LogReorderFilter.flush_all()
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_report_teststatus(report, config):
+ if not isinstance(report, pytest.TestReport):
+ return (yield)
+
+ key = (report.nodeid, report.when)
+ if key not in status_log_seen:
+ status_log_seen.add(key)
+ outcome = (
+ report.outcome.upper() if report.when == "call" or report.failed else "done"
+ )
+ status_log.info(f"\n==== {report.nodeid}: {report.when} {outcome} ====\n")
+ if report.failed:
+ status_log.error(str(report.longrepr))
+ for header, content in report.sections:
+ if header.startswith("Captured log"):
+ continue
+ status_log.error(f"--- {header} ---\n{content}")
+ status_log.error(f"---")
+
+ return (yield)
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_logfinish(nodeid, location):
+ utils.LogReorderFilter.flush_all()
+ yield
+
+
+#
+# Fixtures
+#
+
+
+@pytest.fixture(scope="session")
+def kernel(pytestconfig):
+ """
+ Fixture for kernel image. Skips tests if no kernel available.
+
+ Yields:
+ kernel (str): path to the kernel image
+ """
+ kernel = pytestconfig.getoption("kernel")
+
+ if kernel is None:
+ kernel = os.environ.get("FUNCTIONAL_TESTING_KERNEL")
+
+ if not kernel:
+ pytest.skip("No kernel image")
+
+ if Path(kernel).is_dir():
+ kernel = str(Path(kernel) / "arch" / "x86" / "boot" / "bzImage")
+
+ if not Path(kernel).is_file():
+ pytest.skip("no kernel image")
+
+ return kernel
+
+
+@pytest.fixture(scope="session")
+def usb_indices(pytestconfig):
+ """
+ Fixture for available HW USB controllers. Skips tests if not available.
+
+ Yields:
+ usb_indices: list of usb controller names (hci0, hci1, ...)
+ messages: error messages associated with each
+ """
+ usb_indices = pytestconfig.getoption("usb")
+
+ if usb_indices is None:
+ usb_indices = os.environ.get("FUNCTIONAL_TESTING_CONTROLLERS")
+
+ if usb_indices is None:
+ usb_indices = [item.name for item in Path("/sys/class/bluetooth").iterdir()]
+ else:
+ usb_indices = usb_indices.replace(",", " ").split()
+
+ messages = []
+ for name in list(usb_indices):
+ subsys = Path("/sys/class/bluetooth") / name / "device" / "subsystem"
+ if subsys.resolve() != Path("/sys/bus/usb"):
+ usb_indices.remove(name)
+ continue
+
+ try:
+ env.Environment.check_controller(name)
+ messages.append("")
+ except ValueError as exc:
+ usb_indices.remove(name)
+ messages.append(str(exc))
+
+ return usb_indices, messages
+
+
+@pytest.fixture(scope="session")
+def host_setup(request):
+ """
+ Host setup configuration
+
+ Yields:
+ dict[setup: tuple[HostPlugin], name: str, reuse: bool]
+ """
+ if getattr(request, "param", None) is None:
+ raise pytest.fail("host setup not specified")
+
+ for plugins in request.param.get("setup", ()):
+ for plugin in plugins:
+ plugin.presetup(request.session.config)
+
+ return request.param
+
+
+@pytest.fixture(scope="session")
+def vm_setup(request):
+ """
+ VM setup configuration
+
+ Yields:
+ (num_hosts: int, hw_controllers: bool)
+ """
+ if getattr(request, "param", None) is None:
+ raise pytest.fail("env setup not specified")
+
+ return request.param
+
+
+def _vm_impl(request, kernel, num_hosts, hw, mem):
+ config = request.session.config
+
+ if hw or config.option.force_usb:
+ usb_indices, messages = request.getfixturevalue("usb_indices")
+ if len(usb_indices) < num_hosts:
+ message = "\n".join(m for m in messages[:num_hosts] if m)
+ pytest.skip(reason=f"Not enough USB controllers: {message}")
+ else:
+ usb_indices = None
+
+ with env.Environment(
+ kernel,
+ num_hosts,
+ usb_indices=usb_indices,
+ mem=mem,
+ timeout=utils.DEFAULT_TIMEOUT,
+ ) as vm:
+ yield vm
+
+ _close_hosts(request, vm, vm.reuse_group)
+
+
+def _hosts_impl(request, vm, setup, name, reuse):
+ vm_timeout = utils.DEFAULT_TIMEOUT
+ timeout = vm_timeout
+
+ if not reuse or vm.reuse_group != name:
+ _close_hosts(request, vm, vm.reuse_group)
+
+ # Start VM if it was stopped
+ if vm.path is None:
+ vm.start()
+
+ for idx, (h, plugins) in enumerate(zip(vm.hosts, setup)):
+ timeout = max(vm_timeout * len(plugins) ** 0.5, timeout)
+
+ h.set_instance_name(f"{name}.{idx}")
+
+ for p in plugins:
+ h.start_load(p)
+
+ for h in vm.hosts:
+ h.wait_load(timeout=timeout)
+
+ yield vm.hosts
+
+ if not reuse:
+ _close_hosts(request, vm, name)
+
+ vm.reuse_group = name if reuse else None
+
+
+def _close_hosts(request, vm, name):
+ if name is None:
+ return
+
+ success = True
+ try:
+ vm.close_hosts()
+ except:
+ success = False
+ warnings.warn(traceback.format_exc())
+ finally:
+ _copy_host_files(vm)
+
+ # Stop VM if tester is not responding
+ if not success:
+ vm.stop()
+
+
+def _copy_host_files(vm):
+ for j, h in enumerate(vm.hosts):
+ path = Path(h._path).parent / f"shared-{j}"
+ for f in path.iterdir():
+ if f.name.startswith("test-functional-"):
+ shutil.copyfile(f, f.name)
+ os.unlink(f)
+ if f.name.endswith(".core"):
+ warnings.warn(f"Core dump: {f.name}")
+
+
+@pytest.fixture(scope="package")
+def vm(request, kernel, vm_setup):
+ """
+ Session-scope virtual machine fixture. Used internally by `hosts`.
+
+ Yields:
+ env.Environment
+ """
+ yield from _vm_impl(request, kernel, **vm_setup)
+
+
+@pytest.fixture
+def hosts(request, vm, host_setup):
+ """
+ Session-scope fixture that expands to a list of VM host proxies
+ (`HostProxy`), with configuration as specified in `host_config`. The
+ VM instances used may be reused by other tests. The userspace test
+ runner is torn down between tests.
+
+ Example:
+
+ def test_something(hosts):
+ host0 = hosts[0]
+ host1 = hosts[1]
+ """
+ yield from _hosts_impl(request, vm, **host_setup)
+
+
+# Same with single-test scope:
+
+
+@pytest.fixture
+def vm_once(request, kernel, vm_setup):
+ """
+ Function-scope virtual machine fixture. Used internally by `hosts_once`.
+
+ Yields:
+ env.Environment
+ """
+ yield from _vm_impl(request, kernel, **vm_setup)
+
+
+@pytest.fixture
+def hosts_once(request, vm_module, host_setup):
+ """
+ Function-scope fixture. Same as `hosts`, but spawn separate VM
+ instances for this test only.
+
+ Example:
+
+ def test_something(hosts_once):
+ host0 = hosts_once[0]
+ host1 = hosts_once[1]
+ """
+ yield from _hosts_impl(request, vm_module, **host_setup)
diff --git a/test/pytest_bluez/rpc.py b/test/pytest_bluez/rpc.py
new file mode 100644
index 000000000..098c62f2a
--- /dev/null
+++ b/test/pytest_bluez/rpc.py
@@ -0,0 +1,385 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+Simple RPC over sockets / character devices
+
+"""
+import sys
+import os
+import struct
+import socket
+import fcntl
+import select
+import time
+import pickle
+import logging
+import traceback
+from pathlib import Path
+
+log = logging.getLogger("rpc")
+
+__all__ = [
+ "Connection",
+ "RemoteError",
+ "RemoteTimeoutError",
+ "server_stream",
+ "server_file",
+ "server_unix_socket",
+ "client_unix_socket",
+]
+
+
+class RemoteError(Exception):
+ def __init__(self, exc, traceback):
+ super().__init__(str(exc))
+ self.exc = exc
+ self.traceback = traceback
+
+ def __str__(self):
+ tb = "\n ".join(self.traceback.split("\n"))
+ return f"{self.exc}\nRemote traceback:\n {tb}"
+
+
+class RemoteTimeoutError(TimeoutError):
+ pass
+
+
+def server_stream(stream, implementation):
+ """
+ Run client side on the given stream.
+
+ Parameters
+ ----------
+ stream : file
+ Stream to use for I/O
+ implementation : object
+ Object on which remote methods are called
+
+ """
+ conn = Connection(stream, None)
+
+ while True:
+ try:
+ msg = conn._recv()
+ except BrokenPipeError:
+ log.info("server: end of input")
+ return
+
+ message = msg["message"]
+ ident = msg.get("ident", None)
+
+ if message in ("call", "call-noreply"):
+ log.info(f"server: {msg['method']} {msg['a']} {msg['kw']}")
+ try:
+ method = getattr(implementation, msg["method"])
+ result = method(*msg["a"], **msg["kw"])
+ if message == "call":
+ conn._send("call:reply", result=result, ident=ident)
+ except BaseException as exc:
+ if message == "call":
+ conn._send(
+ "call:reply",
+ error=exc,
+ traceback=traceback.format_exc(),
+ ident=ident,
+ )
+ else:
+ log.error(traceback.format_exc())
+ log.debug(f"server: reply")
+ elif message == "hello":
+ conn._send("hello:reply", ident=ident)
+ elif message == "quit":
+ method = getattr(implementation, "teardown", None)
+ exc_info = {}
+ if method is not None:
+ try:
+ method()
+ except BaseException as exc:
+ log.error(f"implementation quit() failed: {exc}")
+ exc_info = dict(error=exc, traceback=traceback.format_exc())
+
+ log.info(f"server: quit")
+ conn._send("quit:reply", ident=ident, **exc_info)
+ return
+ else:
+ raise RuntimeError(f"unknown {message=}")
+
+
+def server_file(filename, implementation):
+ """Open given file and run server on it"""
+ with open(filename, "r+b", buffering=0) as stream:
+ server_stream(stream, implementation)
+
+
+def server_unix_socket(socket_path, implementation):
+ """Open given file and run server on it"""
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
+ sock.bind(str(socket_path))
+ sock.listen(1)
+
+ s, addr = sock.accept()
+ try:
+ server_stream(s, implementation)
+ finally:
+ s.close()
+
+
+def client_unix_socket(socket_path, timeout=10, name=None):
+ """
+ Connect client to Unix socket
+
+ Parameters
+ ----------
+ socket_path : str
+ Path to Unix socket to bind to and listen
+ proxy_cls : type
+ Proxy class to make instance of
+
+ Returns
+ -------
+ conn : Connection
+ Client connection object
+
+ """
+ log.debug(f"client: connect")
+
+ wait = 0.5
+ end = time.time() + timeout
+ while True:
+ dt = end - time.time()
+ if dt <= 0:
+ raise RemoteTimeoutError("Failed to establish connection")
+
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ sock.connect(str(socket_path))
+ except (FileNotFoundError, ConnectionRefusedError, OSError) as exc:
+ log.debug(f"client: retry connect ({exc})")
+ sock.close()
+ time.sleep(min(0.5, dt))
+ continue
+
+ conn = Connection(sock, timeout, name=name)
+ try:
+ conn._send_reply("hello", timeout=min(wait, dt))
+ break
+ except (BrokenPipeError, TimeoutError) as exc:
+ log.debug(f"client: retry connect ({exc})")
+ sock.close()
+ conn = None
+ wait *= 1.5
+ continue
+
+ log.debug(f"client: connected")
+ return conn
+
+
+class Connection:
+ """
+ Bidirectional message queue on a stream, pickle-based
+ """
+
+ def __init__(self, stream, timeout, name=None):
+ fd = stream.fileno()
+ flags = fcntl.fcntl(fd, fcntl.F_GETFL)
+ fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
+
+ self.stream = stream
+ self.timeout = timeout
+ self._close_async = None
+
+ if name is None:
+ self.log = log
+ else:
+ self.log = logging.getLogger(f"rpc.{name}")
+
+ def _do_recv(self, size):
+ recv = getattr(self.stream, "recv", None) or self.stream.read
+ try:
+ return recv(size)
+ except BlockingIOError:
+ return None
+
+ def _do_send(self, data):
+ send = getattr(self.stream, "send", None) or self.stream.write
+ try:
+ return send(data)
+ except BlockingIOError:
+ return 0
+
+ def _recvall(self, size, timeout=None):
+ if timeout is None:
+ timeout = self.timeout
+ if timeout is not None:
+ end = time.time() + timeout
+
+ data = b""
+ while len(data) < size:
+ if timeout is not None:
+ dt = end - time.time()
+ if dt <= 0:
+ raise RemoteTimeoutError("Connection recv timed out")
+ else:
+ dt = None
+
+ try:
+ r, w, x = select.select([self.stream], [], [self.stream], dt)
+ except ValueError:
+ raise BrokenPipeError()
+
+ if x:
+ raise IOError("Connection failed")
+ elif not r:
+ continue
+
+ s = self._do_recv(size - len(data))
+ if not s:
+ raise BrokenPipeError()
+
+ data += s
+
+ return data
+
+ def _sendall(self, data, timeout=None):
+ if timeout is None:
+ timeout = self.timeout
+ if timeout is not None:
+ end = time.time() + timeout
+
+ while data:
+ if timeout is not None:
+ dt = end - time.time()
+ if dt <= 0:
+ raise RemoteTimeoutError("Connection send timed out")
+ else:
+ dt = None
+
+ try:
+ r, w, x = select.select([], [self.stream], [self.stream], dt)
+ except ValueError:
+ raise BrokenPipeError()
+
+ if x:
+ raise IOError("Connection failed")
+ elif not w:
+ continue
+
+ size = self._do_send(data)
+ if not size:
+ continue
+
+ data = data[size:]
+
+ def _recv(self, timeout=None):
+ (size,) = struct.unpack("<Q", self._recvall(8, timeout=timeout))
+ if size > 2**24:
+ raise ValueError("Invalid size")
+ data = self._recvall(size, timeout=timeout)
+ return pickle.loads(data)
+
+ def _send(self, message, timeout=None, **kw):
+ data = pickle.dumps(
+ dict(message=message, **kw),
+ protocol=pickle.HIGHEST_PROTOCOL,
+ )
+ size = struct.pack("<Q", len(data))
+ self._sendall(size + data, timeout=timeout)
+
+ def _send_reply_async(self, message, timeout=None, **kw):
+ """
+ Send-reply pair. If there are unprocessed messages in
+ input queue (e.g. failed send-reply pair), those are dropped.
+
+ """
+ ident = time.time_ns()
+
+ self._send(message, timeout=timeout, ident=ident, **kw)
+
+ yield
+
+ while True:
+ reply = self._recv(timeout=timeout)
+ if reply["message"] == f"{message}:reply" and reply["ident"] == ident:
+ return reply
+ if reply["message"] == "hello":
+ # hello from different instance on the other side: our
+ # session is gone
+ self.stream.close()
+ raise BrokenPipeError("Session was terminated")
+
+ def _send_reply(self, *a, **kw):
+ try:
+ coro = self._send_reply_async(*a, **kw)
+ coro.send(None)
+ coro.send(None)
+ raise RuntimeError()
+ except StopIteration as exc:
+ return exc.value
+
+ def call_noreply(self, method, *a, **kw):
+ timeout = kw.pop("timeout", None)
+
+ self.log.info(f"client: (noreply) {method} {a} {kw}")
+ self._send("call-noreply", method=str(method), a=a, kw=kw, timeout=timeout)
+
+ def call(self, method, *a, **kw):
+ timeout = kw.pop("timeout", None)
+
+ self.log.info(f"client: {method} {a} {kw}")
+
+ reply = self._send_reply(
+ "call", method=str(method), a=a, kw=kw, timeout=timeout
+ )
+ if reply.get("error"):
+ raise RemoteError(reply["error"], reply["traceback"])
+
+ self.log.debug(f"client-reply")
+ return reply["result"]
+
+ def close(self):
+ """
+ Close connection synchronously
+ """
+ try:
+ self.close_start()
+ finally:
+ self.close_finish()
+
+ def close_start(self):
+ """
+ Initiate connection close
+ """
+ self.log.info(f"client: quit")
+ if self._close_async is not None:
+ raise RuntimeError("double close start")
+ self._close_async = self._send_reply_async("quit")
+ try:
+ self._close_async.send(None)
+ except BrokenPipeError:
+ self._close_async = None
+ except:
+ self._close_async = None
+ raise
+
+ def close_finish(self, force=False):
+ """
+ Finish connection close
+ """
+ try:
+ if self._close_async is not None and not force:
+ self._close_async.send(None)
+ except BrokenPipeError:
+ pass
+ except StopIteration as exc:
+ reply = exc.value
+ if reply.get("error"):
+ raise RemoteError(reply["error"], reply["traceback"])
+ finally:
+ self._close_async = None
+ self.stream.close()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, type, value, tb):
+ self.close()
diff --git a/test/pytest_bluez/runner.py b/test/pytest_bluez/runner.py
new file mode 100644
index 000000000..b533f73d1
--- /dev/null
+++ b/test/pytest_bluez/runner.py
@@ -0,0 +1,20 @@
+#!/usr/bin/python3 -P
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+import sys
+from pathlib import Path
+from importlib.machinery import PathFinder
+
+
+class SelfImport(PathFinder):
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == "pytest_bluez":
+ path = [str(Path(__file__).parent / "..")]
+ return super().find_spec(fullname, path, target)
+
+
+sys.meta_path.insert(0, SelfImport())
+
+import pytest_bluez.env
+
+sys.exit(pytest_bluez.env._main_runner())
diff --git a/test/pytest_bluez/tests/__init__.py b/test/pytest_bluez/tests/__init__.py
new file mode 100644
index 000000000..fe1c85178
--- /dev/null
+++ b/test/pytest_bluez/tests/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
diff --git a/test/pytest_bluez/tests/test_rpc.py b/test/pytest_bluez/tests/test_rpc.py
new file mode 100644
index 000000000..1e7cbc366
--- /dev/null
+++ b/test/pytest_bluez/tests/test_rpc.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+import os
+import pytest
+import subprocess
+import threading
+import traceback
+
+from .. import rpc
+
+
+def test_basic(tmp_path):
+
+ def impl_1(text):
+ print("pid", os.getpid())
+ return f"1: got {text}"
+
+ class Impl2:
+ def method(self, text):
+ print("pid", os.getpid())
+ return f"2: got {text}"
+
+ def error(self):
+ raise FloatingPointError("test")
+
+ socket_1 = tmp_path / "socket.1"
+ socket_2 = tmp_path / "socket.2"
+
+ def server_1():
+ try:
+ rpc.server_unix_socket(socket_1, impl_1)
+ except:
+ traceback.print_exc()
+ raise
+
+ def server_2():
+ try:
+ rpc.server_unix_socket(socket_2, Impl2())
+ except:
+ traceback.print_exc()
+ raise
+
+ s_1 = threading.Thread(target=server_1)
+ s_2 = threading.Thread(target=server_2)
+
+ s_1.start()
+ s_2.start()
+
+ try:
+ with rpc.client_unix_socket(socket_1) as c_1, rpc.client_unix_socket(
+ socket_2
+ ) as c_2:
+ assert c_1.call("__call__", "hello 1") == "1: got hello 1"
+ assert c_2.call("method", "hello 2") == "2: got hello 2"
+ with pytest.raises(rpc.RemoteError, match="Remote traceback"):
+ c_2.call("error")
+ except:
+ traceback.print_exc()
+ raise
+ finally:
+ s_1.join()
+ s_2.join()
diff --git a/test/pytest_bluez/tests/test_utils.py b/test/pytest_bluez/tests/test_utils.py
new file mode 100644
index 000000000..6d56c42ec
--- /dev/null
+++ b/test/pytest_bluez/tests/test_utils.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+import os
+import pytest
+import subprocess
+import threading
+
+from .. import utils
+
+
+def test_log_stream(caplog):
+ with utils.LogStream(__name__) as log_stream:
+ log_stream.stream.write(b"hello")
+
+ (record,) = (r for r in caplog.records if r.name == __name__)
+ assert "hello" in record.message
diff --git a/test/pytest_bluez/utils.py b/test/pytest_bluez/utils.py
new file mode 100644
index 000000000..d6bb21406
--- /dev/null
+++ b/test/pytest_bluez/utils.py
@@ -0,0 +1,706 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+Utilities for end-to-end testing.
+
+"""
+import os
+import sys
+import io
+import re
+import logging
+import subprocess
+import shlex
+import shutil
+import threading
+import time
+import socket
+import struct
+import time
+import select
+import fnmatch
+import heapq
+import tempfile
+import queue
+import functools
+from pathlib import Path
+
+from gi.repository import GLib
+
+__all__ = [
+ "run",
+ "find_exe",
+ "wait_until",
+ "get_bdaddr",
+ "quoted",
+ "mainloop_invoke",
+ "mainloop_wrap",
+ "mainloop_assert",
+ "LogStream",
+]
+
+
+BUILD_DIR = None
+SRC_DIR = None
+DEFAULT_TIMEOUT = 20
+SIMPLE_LOG_HANDLER = None
+
+log = logging.getLogger(f"run")
+
+OUT = 5
+logging.addLevelName(OUT, "OUT")
+
+
+def quoted(args):
+ """
+ Quote shell command
+ """
+ return " ".join(shlex.quote(arg) for arg in args)
+
+
+def bluez_src_dir():
+ if SRC_DIR is not None:
+ return SRC_DIR
+
+ src_dir = Path(__file__).parent / ".." / ".."
+ if (src_dir / "src" / "org.bluez.service").exists():
+ return src_dir
+
+ return None
+
+
+def find_exe(subdir, name):
+ """
+ Find executable, either in BlueZ build tree or system
+ """
+ src = bluez_src_dir()
+ paths = [
+ src and src / "builddir" / subdir / name,
+ src and src / "build" / subdir / name,
+ src and src / subdir / name,
+ shutil.which(name),
+ src and src / subdir / name,
+ f"/usr/libexec/bluetooth/{name}",
+ ]
+ if BUILD_DIR is not None:
+ paths.insert(0, BUILD_DIR / subdir / name)
+ for exe in paths:
+ if exe is None:
+ continue
+ exe = str(exe)
+ if exe and os.path.isfile(exe):
+ return os.path.normpath(exe)
+
+ raise FileNotFoundError(name)
+
+
+def wait_files(jobs, paths, timeout=2):
+ """
+ Wait for subprocess.Popen instances until `paths` have been created.
+ """
+ start = time.time()
+
+ for path in paths:
+ while True:
+ if time.time() > start + timeout:
+ raise TimeoutError(f"Jobs {jobs} timed out")
+ for job in jobs:
+ if job.poll() is not None:
+ raise RuntimeError("Process exited unexpectedly")
+ try:
+ if os.stat(path):
+ break
+ except OSError:
+ time.sleep(0.25)
+
+
+def wait_until(predicate, *a, timeout=None, **kw):
+ if timeout is None:
+ timeout = DEFAULT_TIMEOUT
+
+ count = max(20, round(timeout))
+ for j in range(count):
+ if predicate(*a, **kw):
+ break
+ time.sleep(timeout / count)
+ else:
+ raise TimeoutError("Timeout reached")
+
+
+def get_bdaddr(index=0):
+ """
+ Get bdaddr of controller with given index
+ """
+ btmgmt = find_exe("tools", "btmgmt")
+ res = subprocess.run(
+ [btmgmt, "--index", str(index), "info"],
+ stdout=subprocess.PIPE,
+ check=True,
+ encoding="utf-8",
+ )
+ m = re.search("addr ([A-Z0-9:]+) ", res.stdout)
+ if not m:
+ hciconfig = find_exe("tools", "hciconfig")
+ res = subprocess.run(
+ [hciconfig, f"hci{index}"],
+ stdout=subprocess.PIPE,
+ check=True,
+ encoding="utf-8",
+ )
+ m = re.search("BD Address: ([A-Z0-9:]+)", res.stdout)
+ if not m:
+ raise ValueError("Can't find bdaddr")
+
+ return m.group(1).lower()
+
+
+def mainloop_invoke(func, *a, **kw):
+ """
+ Blocking invoke of `func` in GLib main loop.
+
+ Note:
+
+ GLib main loop is only available for VM host plugins, not in tester.
+
+ Example:
+
+ value = mainloop_invoke(lambda: 123)
+ assert value == 123
+
+ Warning:
+ dbus-python **MUST** be used only from the GLib main loop,
+ as the library has concurrency bugs. All functions using it
+ **MUST** either run from GLib main loop eg. via mainloop_wrap
+ """
+ waits = queue.SimpleQueue()
+
+ def call():
+ value = None
+ try:
+ value = func(*a, **kw)
+ except BaseException as exc:
+ value = exc
+ finally:
+ waits.put(value)
+ return False
+
+ context = GLib.MainContext.default()
+ context.invoke_full(0, call)
+ result = waits.get()
+
+ if isinstance(result, BaseException):
+ raise result
+
+ return result
+
+
+def mainloop_wrap(func):
+ """
+ Wrap function to run in GLib main loop thread
+
+ Note:
+
+ GLib main loop is only available for VM host plugins, not in tester.
+
+ Example:
+
+ @mainloop_wrap
+ def func():
+ bus = dbus.SystemBus()
+ """
+
+ @functools.wraps(func)
+ def wrapper(*a, **kw):
+ return mainloop_invoke(func, *a, **kw)
+
+ return wrapper
+
+
+def mainloop_assert(func):
+ """
+ Wrap function to assert it runs from GLib main loop
+
+ Note:
+
+ GLib main loop is only available for VM host plugins, not in tester.
+
+ Example:
+
+ @mainloop_assert
+ def func():
+ bus = dbus.SystemBus()
+ """
+
+ @functools.wraps(func)
+ def wrapper(*a, **kw):
+ context = GLib.MainContext.default()
+ if not context.is_owner():
+ raise AssertionError("Function not called from GLib mainloop")
+ return func(*a, **kw)
+
+ return wrapper
+
+
+class TmpDir(tempfile.TemporaryDirectory):
+ """Temporary directory in /run; with Python < 3.10 support"""
+
+ def __init__(self, *a, **kw):
+ kw.setdefault("dir", "/run")
+ if sys.version_info >= (3, 10):
+ kw.setdefault("ignore_cleanup_errors", True)
+ super().__init__(*a, **kw)
+
+ def cleanup(self):
+ try:
+ super().cleanup()
+ except:
+ pass
+
+
+def run(*args, input=None, capture_output=False, timeout=None, check=False, **kwargs):
+ """
+ Same as subprocess.run() but log output while running.
+ """
+ if input is not None:
+ if kwargs.get("stdin") is not None:
+ raise ValueError("stdin and input arguments may not both be used.")
+ kwargs["stdin"] = subprocess.PIPE
+
+ if capture_output:
+ if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
+ raise ValueError(
+ "stdout and stderr arguments may not be used " "with capture_output."
+ )
+ kwargs["stdout"] = subprocess.PIPE
+ kwargs["stderr"] = subprocess.PIPE
+
+ stdout = kwargs.get("stdout", None)
+ stderr = kwargs.get("stderr", None)
+ encoding = kwargs.pop("encoding", None)
+ errors = kwargs.pop("errors", "strict")
+
+ stdout_buf = None
+ stderr_buf = None
+
+ if stdout == subprocess.PIPE:
+ stdout = stdout_buf = io.BytesIO()
+ elif isinstance(stdout, int):
+ stdout = None
+
+ stdout_log = LogStream("run.out", tee=stdout)
+ kwargs["stdout"] = stdout_log.stream
+
+ if stderr == subprocess.STDOUT:
+ stderr_log = None
+ else:
+ if stderr == subprocess.PIPE:
+ stderr = stderr_buf = io.BytesIO()
+ elif isinstance(stderr, int):
+ stderr = None
+
+ stderr_log = LogStream("run.err", tee=stderr)
+ kwargs["stderr"] = stderr_log.stream
+
+ log.info(" $ {}".format(quoted(args[0])))
+
+ with subprocess.Popen(*args, **kwargs) as process:
+ try:
+ stdout, stderr = process.communicate(input, timeout=timeout)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ except:
+ process.kill()
+ raise
+ finally:
+ stdout_log.close()
+ if stderr_log is not None:
+ stderr_log.close()
+
+ if stdout_buf is not None:
+ stdout = stdout_buf.getvalue()
+ if encoding not in ("bytes", None):
+ stdout = stdout.decode(encoding=encoding, errors=errors)
+
+ if stderr_buf is not None:
+ stderr = stderr_buf.getvalue()
+ if encoding not in ("bytes", None):
+ stderr = stderr.decode(encoding=encoding, errors=errors)
+
+ retcode = process.poll()
+ if check and retcode:
+ raise subprocess.CalledProcessError(
+ retcode, process.args, output=stdout, stderr=stderr
+ )
+
+ log.info(f"(return code {retcode})")
+
+ return subprocess.CompletedProcess(process.args, retcode, stdout, stderr)
+
+
+class LogStream:
+ """
+ Logger that forwards input from a stream to logging, and
+ optionally tees to another stream. The input pipe is in
+ `LogStream.stream`.
+
+ """
+
+ TS_STRUCT = struct.Struct("@qq")
+ LOG_THREAD = None
+ LOG_THREAD_LOCK = threading.Lock()
+ LOG_QUEUE = queue.SimpleQueue()
+
+ def __init__(self, name, pattern=None, tee=None, stream=None):
+ if pattern is not None:
+ self._logger_pattern = (re.compile(pattern), name)
+ self.log = None
+ else:
+ self._logger_pattern = None
+ self.log = logging.getLogger(name)
+
+ self._filter_re = re.compile(
+ r"\u001b\[=[0-9]+[hl] | \u001b\[\?7l | \u001b\[2J | \u001bc | \n | \r",
+ flags=re.X,
+ )
+
+ # Use SEQPACKET socketpair: this allows obtaining log
+ # timestamps, which is important as we want as close to ns
+ # precision as possible. Read and log data in separate
+ # threads to decouple if output is slow/blocking.
+
+ if stream is None:
+ self._in, self._out = socket.socketpair(
+ socket.AF_UNIX, socket.SOCK_SEQPACKET
+ )
+ self.stream = self._in.makefile("wb")
+ else:
+ self.stream = None
+ self._in = None
+ self._out = stream
+
+ self._tee = tee
+ self._nsec = None
+ self._start_log_thread()
+ self._read_thread = threading.Thread(target=self._run_read)
+ self._read_thread.start()
+ self._flush_event = threading.Event()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ self.close()
+
+ def _run_read(self):
+ buf = b""
+ anc = new_anc = None
+
+ try:
+ # Enable timestamping
+ cmsg_size = socket.CMSG_SPACE(self.TS_STRUCT.size)
+ SO_TIMESTAMPNS_NEW = 64
+ res = self._out.setsockopt(socket.SOL_SOCKET, SO_TIMESTAMPNS_NEW, 1)
+
+ # Read data
+ while True:
+ data, new_anc, _, _ = self._out.recvmsg(4096, cmsg_size)
+ while b"\n" not in data:
+ block = self._out.recv(4096)
+ if not block:
+ break
+ data += block
+
+ if not data:
+ break
+
+ buf += data
+
+ # Log full lines
+ while b"\n" in buf:
+ if anc is None:
+ anc = new_anc
+ j = buf.index(b"\n")
+ self.LOG_QUEUE.put((self, buf[: j + 1], anc))
+ buf = buf[j + 1 :]
+ anc = new_anc if buf else None
+ finally:
+ self._out.close()
+
+ if buf:
+ self.LOG_QUEUE.put((self, buf, new_anc))
+
+ self.LOG_QUEUE.put((self, None, None))
+
+ @staticmethod
+ def _start_log_thread():
+ with LogStream.LOG_THREAD_LOCK:
+ if LogStream.LOG_THREAD is not None:
+ return
+
+ LogStream.LOG_THREAD = threading.Thread(
+ target=LogStream._run_log, daemon=True
+ )
+ LogStream.LOG_THREAD.start()
+
+ @staticmethod
+ def _run_log():
+ # Read data
+
+ while True:
+ logger, data, new_anc = LogStream.LOG_QUEUE.get()
+ if data is None:
+ logger._flush_event.set()
+ continue
+
+ if logger._tee:
+ logger._tee.write(data)
+
+ if data[-2:] == b"\n":
+ data = data[:-1]
+
+ logger._do_log(data, new_anc)
+
+ def _get_time(self, line, anc):
+ if anc:
+ cmsg_data = anc[0][2]
+ sec, nsec = self.TS_STRUCT.unpack(cmsg_data)
+ nsec = sec * 1_000_000_000 + nsec
+ else:
+ nsec = time.time_ns()
+
+ # Force monotonic time
+ if self._nsec is None or self._nsec < nsec:
+ self._nsec = nsec
+ else:
+ self._nsec += 1
+ nsec = self._nsec
+
+ return nsec
+
+ def _do_log(self, line, anc):
+ nsec = self._get_time(line, anc)
+
+ fmt_line = line.decode(errors="surrogateescape")
+ fmt_line = self._filter_re.sub("", fmt_line)
+
+ log = self.log
+ level = OUT
+ if log is None:
+ m = self._logger_pattern[0].match(fmt_line)
+ if m:
+ name = "{}.{}".format(self._logger_pattern[1], m.group(1))
+ fmt_line = fmt_line[: m.start()] + fmt_line[m.end() :]
+ try:
+ level = int(m.group(2))
+ nsec = int(m.group(3))
+ except ValueError:
+ pass
+ else:
+ name = self._logger_pattern[1]
+ else:
+ name = log.name
+
+ if SIMPLE_LOG_HANDLER is not None:
+ SIMPLE_LOG_HANDLER.emit_simple(name, level, fmt_line, nsec)
+ return
+
+ if log is None:
+ log = logging.getLogger(name)
+
+ record = _OutputLogRecord(log.name, level, fmt_line, nsec)
+ log.handle(record)
+
+ def close(self, flush=True):
+ if self._read_thread is not None:
+ if self._in is not None:
+ self.stream.close()
+ self._in.shutdown(socket.SHUT_RDWR)
+ self._in.close()
+ else:
+ try:
+ self._out.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+
+ self._read_thread.join()
+ self._read_thread = None
+ self._flush_event.wait()
+
+ def __del__(self):
+ self.close()
+
+
+class _OutputLogRecord(logging.LogRecord):
+ """Minimal (faster) log record for output lines"""
+
+ def __init__(self, name, level, msg, nsec):
+ self.args = ()
+ self.pathname = ""
+ self.filename = ""
+ self.module = ""
+ self.exc_info = None
+ self.exc_text = None
+ self.stack_info = None
+ self.lineno = 0
+ self.funcName = ""
+ self.thread = None
+ self.threadName = None
+ self.processName = None
+ self.taskName = None
+ self.relativeCreated = None
+
+ self.name = name
+ self.msg = msg
+ self.levelname = logging.getLevelName(level)
+ self.levelno = level
+ self.nsec = nsec
+ self.created = nsec / 1e9
+ self.msecs = (nsec % 1_000_000_000) / 1_000_000 + 0.0
+
+
+class LogNameFilter(logging.Filter):
+ """
+ Filter based on fnmatch deny/allow patterns on logger name
+ """
+
+ def __init__(self, allow=(), deny=()):
+ if allow:
+ allow_re = "|".join(self._re(x) for x in allow)
+ self.allow = re.compile(allow_re)
+ else:
+ self.allow = None
+
+ if deny:
+ deny_re = "|".join(self._re(x) for x in deny)
+ self.deny = re.compile(deny_re)
+ else:
+ self.deny = None
+
+ def _re(self, name):
+ pat = fnmatch.translate(name)
+ return f"{pat}$|{pat}\\."
+
+ def filter(self, record):
+ if self.deny is not None and self.deny.match(record.name):
+ return False
+ if self.allow is not None and not self.allow.match(record.name):
+ return False
+ return True
+
+ @classmethod
+ def enable(cls, filterers, allow=(), deny=()):
+ """
+ Enable filter for all of the filterers
+ """
+ f = cls(allow, deny)
+
+ for h in filterers:
+ if any(isinstance(f, cls) for f in h.filters):
+ continue
+ h.addFilter(f)
+
+ @classmethod
+ def disable(cls, filterers):
+ """
+ Disable filter for all of the filterers
+ """
+ for h in filterers:
+ for f in list(h.filters):
+ if isinstance(f, cls):
+ h.removeFilter(f)
+
+
+class LogReorderFilter(logging.Filter):
+ """
+ Reorder handler's log records based on timestamp
+ """
+
+ FLUSH_THREAD = None
+ FLUSH_ITEMS = []
+ FLUSH_END = threading.Event()
+
+ def __init__(self, handler):
+ self._handler = handler
+ self._queue = []
+ self._records = {}
+ self._pos = time.time_ns()
+ self._delay = 1000_000_000
+ self._lock = threading.Lock()
+
+ def filter(self, record):
+ if getattr(record, "reordered", False):
+ return True
+ self._push(record)
+ self._flush()
+ return False
+
+ def _push(self, record):
+ ts = getattr(record, "nsec", int(record.created * 1e9))
+ with self._lock:
+ heapq.heappush(self._queue, (ts, id(record)))
+ self._records[id(record)] = record
+ self._pos = max(self._pos, ts)
+
+ self._delay = max(self._delay, 2 * (time.time_ns() - ts))
+
+ def _flush(self, force=False):
+ with self._lock:
+ while self._queue and (
+ self._queue[0][0] + self._delay < self._pos or force
+ ):
+ ts, rid = heapq.heappop(self._queue)
+ record = self._records.pop(rid)
+ record.reordered = True
+ self._handler.handle(record)
+
+ @classmethod
+ def flush_all(cls):
+ """
+ Flush all log reorder filters added via enable()
+ """
+ for f in cls.FLUSH_ITEMS:
+ f._flush(force=True)
+
+ @classmethod
+ def enable(cls, filterers):
+ """
+ Enable reordering for all of the filterers
+ """
+ for h in filterers:
+ if any(isinstance(f, cls) for f in h.filters):
+ continue
+
+ f = cls(h)
+ cls.FLUSH_ITEMS.append(f)
+ h.addFilter(f)
+
+ if cls.FLUSH_THREAD is None and cls.FLUSH_ITEMS:
+ cls.FLUSH_END.clear()
+ cls.FLUSH_THREAD = threading.Thread(target=cls._flush_thread, daemon=True)
+ cls.FLUSH_THREAD.start()
+
+ @classmethod
+ def disable(cls, filterers):
+ """
+ Disable reordering for all of the filterers
+ """
+ for h in filterers:
+ for f in list(h.filters):
+ if not isinstance(f, cls):
+ continue
+
+ cls.FLUSH_ITEMS.remove(f)
+ h.removeFilter(f)
+ f._flush(force=True)
+
+ if cls.FLUSH_THREAD is not None and cls.FLUSH_ITEMS:
+ cls.FLUSH_END.set()
+ cls.FLUSH_THREAD.join()
+
+ @classmethod
+ def _flush_thread(cls):
+ # Timed flushing
+ while not cls.FLUSH_END.wait(1.0):
+ for f in cls.FLUSH_ITEMS:
+ f._flush()
diff --git a/test/test-functional b/test/test-functional
new file mode 100755
index 000000000..a739a9554
--- /dev/null
+++ b/test/test-functional
@@ -0,0 +1,7 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# See doc/test-functional.rst
+#
+export PYTHONPATH="$(dirname "$0")"
+exec python3 -m pytest -c "$(dirname "$0")"/pytest.ini "$@"
diff --git a/test/test-functional-attach b/test/test-functional-attach
new file mode 100755
index 000000000..259a25bd9
--- /dev/null
+++ b/test/test-functional-attach
@@ -0,0 +1,52 @@
+#!/usr/bin/python3
+"""
+%(prog)s
+
+Start Tmux and connect to active test-functional VM hosts.
+"""
+import re
+import os
+import sys
+import argparse
+import subprocess
+import tempfile
+from pathlib import Path
+
+def main():
+ p = argparse.ArgumentParser(usage=__doc__.strip())
+ args = p.parse_args()
+
+ sockets = []
+
+ tmpdir = Path(tempfile.gettempdir())
+ for d in tmpdir.iterdir():
+ if not d.name.startswith("bluez-func-test-"):
+ continue
+
+ for s in d.iterdir():
+ if s.name.startswith("bluez-func-test-tty-"):
+ sockets.append(s)
+
+ if not sockets:
+ print(f"No sockets in {tmpdir} to attach to")
+ return
+
+ session = "bluez-func-test"
+
+ for j, sock in enumerate(sockets):
+ name = re.sub(r".*tty-", "host", sock.name)
+
+ cmd = ["tmux"]
+ if j == 0:
+ cmd += ['new-session', '-d', '-n', name, "-s", session]
+ else:
+ cmd += ['new-window', '-d', '-n', name, "-t", f"{session}:"]
+ cmd += ["--", "socat", sock, "STDIO,rawer"]
+
+ subprocess.run(cmd, check=True)
+
+ os.execvp("tmux", ["tmux", "attach-session", "-t", session])
+
+if __name__ == "__main__":
+ main()
+ sys.exit(0)
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 13/20] test: functional: add Pipewire-using audio streaming tests
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (11 preceding siblings ...)
2026-03-22 21:30 ` [PATCH BlueZ v3 12/20] test: add functional/integration testing framework Pauli Virtanen
@ 2026-03-22 21:30 ` Pauli Virtanen
2026-03-22 21:30 ` [PATCH BlueZ v3 14/20] test: functional: add --btmon option to start btmon Pauli Virtanen
` (6 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:30 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Add tests for Pipewire hosts connecting A2DP, BAP, and HFP to each other
and stream audio:
test/functional/test_pipewire.py::test_pipewire_a2dp
test/functional/test_pipewire.py::test_pipewire_bap_bcast
test/functional/test_pipewire.py::test_pipewire_bap_ucast
test/functional/test_pipewire.py::test_pipewire_hfp
The A2DP test would have catched the regression fixed by 066a164a524 and
the BAP test that fixed by 6b0a08776a.
---
test/functional/test_pipewire.py | 539 +++++++++++++++++++++++++++++++
test/pytest.ini | 1 +
2 files changed, 540 insertions(+)
create mode 100644 test/functional/test_pipewire.py
diff --git a/test/functional/test_pipewire.py b/test/functional/test_pipewire.py
new file mode 100644
index 000000000..d80d11d3e
--- /dev/null
+++ b/test/functional/test_pipewire.py
@@ -0,0 +1,539 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+Tests for Pipewire audio
+
+To use uninstalled version of Pipewire, run the tests in Pipewire
+devenv::
+
+ meson devenv -C ../pipewire/builddir -w . test/test-functional -m pipewire -v
+"""
+import sys
+import os
+import re
+import pytest
+import subprocess
+import tempfile
+import time
+import logging
+import json
+import dbus
+import threading
+from pathlib import Path
+
+import pytest
+
+from pytest_bluez import (
+ HostPlugin,
+ host_config,
+ find_exe,
+ Bluetoothd,
+ Bluetoothctl,
+ DbusSession,
+ LogStream,
+ wait_until,
+ mainloop_wrap,
+)
+
+pytestmark = [pytest.mark.vm, pytest.mark.pipewire]
+
+log = logging.getLogger(__name__)
+
+# Use larger VM instances in case ASAN is enabled
+VM_MEM = "512M"
+
+
+class Pipewire(HostPlugin):
+ """
+ Launch Pipewire in VM instance
+ """
+
+ name = "pipewire"
+ depends = [DbusSession(), Bluetoothd()]
+
+ def __init__(
+ self,
+ uuids=(
+ "0000110a-0000-1000-8000-00805f9b34fb",
+ "0000110b-0000-1000-8000-00805f9b34fb",
+ ),
+ roles="a2dp_sink a2dp_source",
+ config=None,
+ ):
+ self.uuids = tuple(uuids)
+ self.roles = str(roles)
+ self.config = config
+
+ # For running Pipewire from build directory
+ self.devenv = {}
+ if os.environ.get("PW_UNINSTALLED"):
+ devenv_keys = [
+ "WIREPLUMBER_MODULE_DIR",
+ "WIREPLUMBER_CONFIG_DIR",
+ "WIREPLUMBER_DATA_DIR",
+ "PIPEWIRE_CONFIG_DIR",
+ "PIPEWIRE_MODULE_DIR",
+ "SPA_PLUGIN_DIR",
+ "SPA_DATA_DIR",
+ "ACP_PATHS_DIR",
+ "ACP_PROFILES_DIR",
+ "GST_PLUGIN_PATH",
+ "ALSA_PLUGIN_DIR",
+ "LD_LIBRARY_PATH",
+ "PW_UNINSTALLED",
+ "PW_BUILDDIR",
+ ]
+ for key in devenv_keys:
+ value = os.environ.get(key)
+ if value is not None:
+ self.devenv[key] = value
+
+ def presetup(self, config):
+ try:
+ self.exe_pw = find_exe("", "pipewire")
+ self.exe_wp = find_exe("", "wireplumber")
+ self.exe_dump = find_exe("", "pw-dump")
+
+ # check version
+ res = subprocess.run(
+ [self.exe_wp, "--version"], stdout=subprocess.PIPE, encoding="utf-8"
+ )
+ m = re.search("libwireplumber ([0-9.]+)", res.stdout)
+ if m:
+ version = tuple(int(x) for x in m.group(1).split("."))
+ if version < (0, 5, 8):
+ raise ValueError("wireplumber too old")
+ else:
+ raise ValueError("wireplumber version unknown")
+ except (FileNotFoundError, ValueError) as exc:
+ pytest.skip(reason=f"Pipewire: {exc}")
+
+ @mainloop_wrap
+ def setup(self, impl):
+ self.play = None
+ self.record = None
+ self.log = logging.getLogger(self.name)
+
+ self.tmpdir = tempfile.TemporaryDirectory(prefix="pipewire-", dir="/run")
+ conf_dir = Path(self.tmpdir.name) / "config"
+ runtime_dir = Path(self.tmpdir.name) / "runtime"
+ state_dir = Path(self.tmpdir.name) / "state"
+
+ dropin_dir = conf_dir / "wireplumber" / "wireplumber.conf.d"
+ wp_conf = dropin_dir / "01-config.conf"
+ wp_extra_conf = dropin_dir / "02-extra-config.conf"
+
+ conf_dir.mkdir()
+ runtime_dir.mkdir()
+ dropin_dir.mkdir(parents=True)
+ state_dir.mkdir()
+
+ environ = dict(os.environ)
+ environ.update(self.devenv)
+
+ environ["XDG_CONFIG_HOME"] = str(conf_dir)
+ environ["XDG_STATE_HOME"] = str(runtime_dir)
+ environ["XDG_RUNTIME_HOME"] = str(runtime_dir)
+ environ["PIPEWIRE_RUNTIME_DIR"] = str(runtime_dir)
+ environ["XDG_STATE_HOME"] = str(state_dir)
+ environ["PIPEWIRE_DEBUG"] = "2"
+ environ["WIREPLUMBER_DEBUG"] = (
+ "spa.bluez5.iso:3,spa.bluez5*:4,s-monitors:4,m-lua-scripting:4,s-linking:4,s-device:4"
+ )
+
+ # Handle devenv
+ if "WIREPLUMBER_CONFIG_DIR" in environ:
+ environ["WIREPLUMBER_CONFIG_DIR"] = (
+ environ["WIREPLUMBER_CONFIG_DIR"] + ":" + str(conf_dir / "wireplumber")
+ )
+
+ with open(wp_conf, "w") as f:
+ text = f"""
+ monitor.bluez.properties = {{
+ bluez5.roles = [ {self.roles} ]
+ bluez5.decode-buffer.latency = 4096
+ }}
+ """
+ f.write(text)
+
+ if self.config is not None:
+ with open(wp_extra_conf, "w") as f:
+ f.write(self.config)
+
+ log.info(f"Starting pipewire: {self.exe_pw}")
+
+ self.logger = LogStream("pipewire")
+ self.pw = subprocess.Popen(
+ self.exe_pw,
+ env=environ,
+ stdout=self.logger.stream,
+ stderr=subprocess.STDOUT,
+ )
+
+ log.info(f"Starting wireplumber: {self.exe_wp}")
+
+ self.wp = subprocess.Popen(
+ self.exe_wp,
+ env=environ,
+ stdout=self.logger.stream,
+ stderr=subprocess.STDOUT,
+ )
+
+ # Wait for Pipewire's bluetooth services
+ log.info("Wait for Pipewire...")
+ bus = dbus.SystemBus()
+ bus.set_exit_on_disconnect(False)
+ adapter = dbus.Interface(
+ bus.get_object("org.bluez", "/org/bluez/hci0"),
+ "org.freedesktop.DBus.Properties",
+ )
+
+ def cond():
+ uuids = [str(uuid) for uuid in adapter.Get("org.bluez.Adapter1", "UUIDs")]
+ return all(uuid in uuids for uuid in self.uuids)
+
+ wait_until(cond)
+
+ os.environ["PIPEWIRE_RUNTIME_DIR"] = str(runtime_dir)
+
+ # Wait for wireplumber session services
+ def cond():
+ try:
+ data = json.loads(self.pw_dump())
+ except:
+ return False
+ for item in data:
+ if item.get("type", None) != "PipeWire:Interface:Client":
+ continue
+ if item["info"]["props"]["application.name"] != "WirePlumber":
+ continue
+ if "api.bluez" in item["info"]["props"].get("session.services", ""):
+ return True
+ return False
+
+ wait_until(cond)
+
+ log.info("Pipewire ready")
+
+ def pw_dump(self):
+ ret = subprocess.run(["pw-dump"], stdout=subprocess.PIPE, encoding="utf-8")
+ return ret.stdout
+
+ def pw_play(self):
+ self.play = subprocess.Popen(
+ [
+ "pw-play",
+ "--raw",
+ "--rate",
+ "4000",
+ "--channels",
+ "1",
+ "--format",
+ "s8",
+ "-",
+ ],
+ stdin=subprocess.PIPE,
+ )
+ self.play_thread = threading.Thread(
+ target=self._play_thread, args=(self.play.stdin,)
+ )
+ self.play_thread.start()
+
+ def _play_thread(self, stream):
+ block = bytes([j % 256 for j in range(4096)])
+ while True:
+ try:
+ stream.write(block)
+ except:
+ self.log.info("pw_play ended")
+ break
+
+ def pw_record(self):
+ self.record = subprocess.Popen(
+ [
+ "pw-record",
+ "-P",
+ "media.class=Audio/Sink",
+ "--raw",
+ "--format",
+ "s8",
+ "--rate",
+ "4000",
+ "--channels",
+ "1",
+ "-",
+ ],
+ stdout=subprocess.PIPE,
+ )
+ self.record_thread = threading.Thread(
+ target=self._record_thread, args=(self.record.stdout,)
+ )
+ self.record_thread.start()
+ self.record_signal = threading.Event()
+
+ def _record_thread(self, stream):
+ while True:
+ try:
+ block = stream.read(256)
+ if not block:
+ break
+ except:
+ self.log.info("pw_record failed")
+ break
+
+ # If we get anything nonzero, some signal is getting
+ # through. Can't check exactness due to encoding and
+ # possibly heavy underruns in VM environment.
+ if any(list(block)):
+ self.log.info("pw_record signal found")
+ self.record_success = True
+ self.record_signal.set()
+ return
+ else:
+ self.log.debug("pw_record: waiting for signal")
+
+ self.log.error("pw_record: no signal found")
+ self.record_success = False
+ self.record_signal.set()
+
+ def pw_record_wait_signal(self, timeout=160):
+ res = self.record_signal.wait(timeout=timeout)
+ return res and self.record_success
+
+ def teardown(self):
+ log.info("Stop pipewire")
+ self.pw.terminate()
+ self.wp.terminate()
+ if self.play is not None:
+ try:
+ self.play.stdin.close()
+ except BrokenPipeError:
+ pass
+ self.play.terminate()
+ self.play_thread.join()
+ if self.record is not None:
+ try:
+ self.record.stdout.close()
+ except BrokenPipeError:
+ pass
+ self.record.terminate()
+ self.record_thread.join()
+ self.tmpdir.cleanup()
+
+
+@pytest.fixture
+def paired_hosts(hosts, host_setup):
+ from .test_bluetoothctl_vm import (
+ test_bluetoothctl_pair_bredr,
+ test_bluetoothctl_pair_le,
+ )
+
+ le = any(
+ "ControllerMode = le" in (p.conf or "")
+ for plugins in host_setup["setup"]
+ for p in plugins
+ if isinstance(p, Bluetoothd)
+ )
+
+ if le:
+ test_bluetoothctl_pair_le(hosts)
+ else:
+ test_bluetoothctl_pair_bredr(hosts)
+
+ return hosts
+
+
+a2dp_host = [Bluetoothctl(), Pipewire(roles="a2dp_sink a2dp_source")]
+
+
+@host_config(a2dp_host, a2dp_host, mem=VM_MEM)
+def test_pipewire_a2dp(paired_hosts):
+ host0, host1 = paired_hosts
+
+ # Connect
+ host1.bluetoothctl.send(f"trust {host0.bdaddr}\n")
+ host0.bluetoothctl.send(f"connect {host1.bdaddr}\n")
+
+ # Wait for pipewire devices to appear
+ check_pipewire_devices_exist(host0, "a2dp-sink")
+
+ # Test streaming
+ host1.pipewire.pw_record()
+ host0.pipewire.pw_play()
+
+ assert host1.pipewire.pw_record_wait_signal()
+
+
+bap_ucast_host = [
+ Bluetoothd(conf="[General]\nControllerMode = le\n", args=["-E", "-K"]),
+ Bluetoothctl(),
+ Pipewire(
+ roles="bap_sink bap_source", uuids=("00001850-0000-1000-8000-00805f9b34fb",)
+ ),
+]
+
+
+@host_config(bap_ucast_host, bap_ucast_host, mem=VM_MEM)
+def test_pipewire_bap_ucast(paired_hosts):
+ host0, host1 = paired_hosts
+
+ # Connect
+ host1.bluetoothctl.send(f"trust {host0.bdaddr}\n")
+
+ host0.bluetoothctl.send(f"scan off\n")
+ host0.bluetoothctl.send(f"connect {host1.bdaddr}\n")
+
+ # Wait for pipewire devices to appear
+ check_pipewire_devices_exist(host0, "bap-sink")
+
+ # Test streaming
+ host1.pipewire.pw_record()
+ host0.pipewire.pw_play()
+
+ assert host1.pipewire.pw_record_wait_signal()
+
+
+bcast_src_config = """
+monitor.bluez.properties = {
+ bluez5.bcast_source.config = [
+ {
+ "broadcast_code": "Test",
+ "encryption": false,
+ "bis": [ { "qos_preset": "16_2_1" } ]
+ }
+ ]
+}
+"""
+
+bap_bcast_src_host = [
+ Bluetoothd(conf="[General]\nControllerMode = le\n", args=["-E", "-K"]),
+ Bluetoothctl(),
+ Pipewire(
+ roles="bap_bcast_source",
+ uuids=("00001850-0000-1000-8000-00805f9b34fb",),
+ config=bcast_src_config,
+ ),
+]
+
+bap_bcast_snk_host = [
+ Bluetoothd(conf="[General]\nControllerMode = le\n", args=["-E", "-K"]),
+ Bluetoothctl(),
+ Pipewire(roles="bap_bcast_sink", uuids=("00001850-0000-1000-8000-00805f9b34fb",)),
+]
+
+
+@host_config(bap_bcast_src_host, bap_bcast_snk_host, mem=VM_MEM)
+def test_pipewire_bap_bcast(hosts):
+ host0, host1 = hosts
+
+ # Start broadcasting
+ check_pipewire_devices_exist(host0, "bap-sink")
+ host0.pipewire.pw_play()
+
+ # Connect
+ host1.bluetoothctl.send(f"scan on\n")
+
+ host0.bluetoothctl.send(f"advertise on\n")
+
+ host1.pipewire.pw_record()
+
+ idx, m = host1.bluetoothctl.expect(f"Transport (/org/bluez/hci0/.+)")
+ transport = m[0].decode("utf-8")
+
+ # BUG!: issuing transport select immediately causes failure
+ # BUG!: as it tries to enter broadcasting state while config(1)
+ # BUG!: is not finished and BROADCASTING state gets cancelled via
+ # BUG!: transport.c:bap_state_changed()
+ # BUG!: -> transport_update_playing(transport, FALSE)
+ # BUG!: -> transport_set_state(transport, TRANSPORT_STATE_IDLE)
+
+ # TODO: fix the bug and go to transport.select without waiting here
+ check_pipewire_devices_exist(host1, "device")
+
+ host1.bluetoothctl.send(f"transport.select {transport}\n")
+
+ check_pipewire_devices_exist(host1, "bap-source")
+
+ # Test streaming
+ assert host1.pipewire.pw_record_wait_signal()
+
+
+hfp_hf_host = [
+ Bluetoothctl(),
+ Pipewire(
+ roles="hfp_hf",
+ uuids=("0000111e-0000-1000-8000-00805f9b34fb",),
+ ),
+]
+
+hfp_ag_host = [
+ Bluetoothctl(),
+ Pipewire(
+ roles="hfp_ag",
+ uuids=("0000111f-0000-1000-8000-00805f9b34fb",),
+ ),
+]
+
+
+@host_config(hfp_ag_host, hfp_hf_host, mem=VM_MEM)
+def test_pipewire_hfp(paired_hosts):
+ host0, host1 = paired_hosts
+
+ # Connect
+ host1.bluetoothctl.send(f"trust {host0.bdaddr}\n")
+
+ host0.bluetoothctl.send(f"scan off\n")
+ host0.bluetoothctl.send(f"connect {host1.bdaddr}\n")
+
+ # Wait for pipewire devices to appear
+ check_pipewire_devices_exist(host0, "hfp")
+
+ # Test streaming
+ host1.pipewire.pw_record()
+ host0.pipewire.pw_play()
+
+ assert host1.pipewire.pw_record_wait_signal()
+
+
+def check_pipewire_devices_exist(host, profile="a2dp-sink"):
+ factories = {
+ "a2dp-sink": ("api.bluez5.a2dp.sink",),
+ "a2dp-source": ("api.bluez5.a2dp.source",),
+ "hfp": ("api.bluez5.sco.sink", "api.bluez5.sco.source"),
+ "bap-sink": ("api.bluez5.media.sink",),
+ "bap-source": ("api.bluez5.media.source",),
+ "bap-duplex": ("api.bluez5.media.sink", "api.bluez5.media.source"),
+ "device": ("bluez5",),
+ }[profile]
+
+ text = ""
+
+ def cond():
+ nonlocal text
+
+ text = host.pipewire.pw_dump()
+ try:
+ data = json.loads(text)
+ except:
+ return False
+
+ seen = set()
+ for item in data:
+ if item.get("type", None) == "PipeWire:Interface:Node":
+ props = item["info"]["props"]
+ seen.add(props.get("factory.name", None))
+ continue
+ if item.get("type", None) == "PipeWire:Interface:Device":
+ props = item["info"]["props"]
+ seen.add(props.get("device.api", None))
+ continue
+
+ if not set(factories).difference(seen):
+ return True
+
+ return False
+
+ try:
+ wait_until(cond)
+ except TimeoutError:
+ assert False, f"pipewire devices not seen within timeout:\n{text}"
diff --git a/test/pytest.ini b/test/pytest.ini
index 899871fa2..e77d7e4ee 100644
--- a/test/pytest.ini
+++ b/test/pytest.ini
@@ -4,6 +4,7 @@ log_date_format = %Y-%m-%d %H:%M:%S.%f
log_level = 0
log_file = test-functional.log
markers =
+ pipewire: tests requiring Pipewire
vm: tests requiring VM image
addopts =
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 14/20] test: functional: add --btmon option to start btmon
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (12 preceding siblings ...)
2026-03-22 21:30 ` [PATCH BlueZ v3 13/20] test: functional: add Pipewire-using audio streaming tests Pauli Virtanen
@ 2026-03-22 21:30 ` Pauli Virtanen
2026-03-22 21:31 ` [PATCH BlueZ v3 15/20] build: add functional testing target Pauli Virtanen
` (5 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:30 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
After test finishes, any btmon dumps created are copied to current
working directory.
---
doc/test-functional.rst | 9 +++
test/pytest_bluez/__init__.py | 1 +
test/pytest_bluez/btmon.py | 116 ++++++++++++++++++++++++++++++++++
test/pytest_bluez/plugin.py | 33 +++++++---
4 files changed, 150 insertions(+), 9 deletions(-)
create mode 100644 test/pytest_bluez/btmon.py
diff --git a/doc/test-functional.rst b/doc/test-functional.rst
index f51401930..8c392ff73 100644
--- a/doc/test-functional.rst
+++ b/doc/test-functional.rst
@@ -34,6 +34,9 @@ The following additional options apply:
environment variable is used. If none, all USB controllers
with suitable permissions are considered.
+:--btmon: Launch btmon on all hosts to log events, and dump traffic to
+ test-functional-\*.btsnoop
+
:--force-usb: Force tests to use USB controllers instead of `btvirt`.
:--vm-timeout=<seconds>: Specify timeout for communication with VM hosts.
@@ -726,6 +729,12 @@ Bluetoothd
Host plugin starting Bluetoothd.
+Btmon
+-----
+
+Host plugin providing btmon running in the background.
+Usually should be loaded via `--btmon`.
+
Call
----
diff --git a/test/pytest_bluez/__init__.py b/test/pytest_bluez/__init__.py
index cb4dca71c..9e967d472 100644
--- a/test/pytest_bluez/__init__.py
+++ b/test/pytest_bluez/__init__.py
@@ -6,5 +6,6 @@ from .rpc import RemoteError
from .env import *
from .utils import *
from .host_plugins import *
+from .btmon import *
from .plugin import *
diff --git a/test/pytest_bluez/btmon.py b/test/pytest_bluez/btmon.py
new file mode 100644
index 000000000..6a03353dd
--- /dev/null
+++ b/test/pytest_bluez/btmon.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+VM host plugin for btmon
+"""
+import os
+import re
+import subprocess
+import tempfile
+import logging
+import time
+
+from . import env, utils
+
+__all__ = [
+ "Btmon",
+]
+
+
+class Btmon(env.HostPlugin):
+ """
+ Host plugin running btmon and forwarding output to logging. Parses
+ timestamps output by btmon.
+
+ """
+
+ name = "btmon"
+
+ def __init__(self, args=None):
+ self.args = None if args is None else list(args)
+ self.end_time = None
+
+ def setup(self, impl):
+ self.log = logging.getLogger(self.name)
+
+ subprocess.run(["mount"])
+
+ self.dumpfile = f"/run/shared/test-functional-{impl.instance_name}.btsnoop"
+
+ if self.args is None:
+ self.args = [
+ "-S",
+ "-A",
+ "-I",
+ "--color=always",
+ f"--columns=160",
+ "-w",
+ self.dumpfile,
+ ]
+
+ exe = utils.find_exe("monitor", "btmon")
+ self.log_stream = BtmonLogStream("btmon")
+ cmd = [exe, "-T"] + self.args
+ self.log_stream.log.info("Starting btmon: {}".format(utils.quoted(cmd)))
+ self.job = subprocess.Popen(
+ cmd,
+ stdin=subprocess.DEVNULL,
+ stdout=self.log_stream.stream,
+ stderr=subprocess.STDOUT,
+ )
+
+ def stop(self):
+ if self.job.poll() is None:
+ self.job.terminate()
+
+ if self.end_time is None:
+ self.end_time = time.time_ns()
+
+ def teardown(self):
+ self.stop()
+
+ self.log.info("Wait for btmon shutdown...")
+ while self.job.poll() is None and self.log_stream._nsec < self.end_time:
+ time.sleep(0.5)
+
+ self.log_stream.close()
+
+ self.job.kill()
+ self.job.wait()
+
+ self.log.info("Teardown done.")
+
+
+class BtmonLogStream(utils.LogStream):
+ """
+ Log streams that parses timestamps from btmon output
+ """
+
+ def __init__(self, name):
+ super().__init__(name)
+ self._localtime_tail = time.localtime()[6:]
+ self._time_pat = re.compile(
+ rb"\s(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)\.(\d+)(?:$|\x1b)"
+ )
+
+ def _get_time(self, line, anc):
+ m = self._time_pat.search(line)
+ if m:
+ m = m.groups()
+ ts = (
+ int(m[0]),
+ int(m[1]),
+ int(m[2]),
+ int(m[3]),
+ int(m[4]),
+ int(m[5]),
+ ) + self._localtime_tail
+ ts = time.mktime(ts) + int(m[6]) / 10 ** len(m[6])
+ self._nsec = int(ts * 1e9)
+
+ if self._nsec is None:
+ return super()._get_time(line, anc)
+
+ nsec = self._nsec
+ self._nsec += 1
+ return nsec
diff --git a/test/pytest_bluez/plugin.py b/test/pytest_bluez/plugin.py
index a713b2f90..2badcb3be 100644
--- a/test/pytest_bluez/plugin.py
+++ b/test/pytest_bluez/plugin.py
@@ -11,6 +11,7 @@ from pathlib import Path
import pytest
from . import utils, env
+from .btmon import Btmon
__all__ = [
@@ -110,6 +111,11 @@ def pytest_addoption(parser):
parser.addini(
"vm_timeout", "Default timeout for communication with VM etc.", default="30"
)
+ group.addoption(
+ "--btmon",
+ action="store_true",
+ help="Launch btmon on all hosts to log events, and dump traffic to test-functional-*.btsnoop",
+ )
# host_plugins.Rcvbuf:
parser.addini(
@@ -459,6 +465,9 @@ def _hosts_impl(request, vm, setup, name, reuse):
h.set_instance_name(f"{name}.{idx}")
+ if request.session.config.option.btmon:
+ plugins = (Btmon(),) + plugins
+
for p in plugins:
h.start_load(p)
@@ -477,18 +486,24 @@ def _close_hosts(request, vm, name):
if name is None:
return
- success = True
try:
- vm.close_hosts()
- except:
- success = False
- warnings.warn(traceback.format_exc())
+ if request.session.config.option.btmon and name is not None:
+ for h in vm.hosts:
+ if hasattr(h, "btmon"):
+ h.btmon._call_noreply("teardown")
finally:
- _copy_host_files(vm)
+ success = True
+ try:
+ vm.close_hosts()
+ except:
+ success = False
+ warnings.warn(traceback.format_exc())
+ finally:
+ _copy_host_files(vm)
- # Stop VM if tester is not responding
- if not success:
- vm.stop()
+ # Stop VM if tester is not responding
+ if not success:
+ vm.stop()
def _copy_host_files(vm):
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 15/20] build: add functional testing target
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (13 preceding siblings ...)
2026-03-22 21:30 ` [PATCH BlueZ v3 14/20] test: functional: add --btmon option to start btmon Pauli Virtanen
@ 2026-03-22 21:31 ` Pauli Virtanen
2026-03-22 21:31 ` [PATCH BlueZ v3 16/20] test: functional: impose Python code formatting Pauli Virtanen
` (4 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:31 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
This adds check-functional: target that runs the functional test suite.
Also add a --enable-functional-testing=<kernel-image> argument for
configure that can be used to include it in the check: make target,
possibly with a predefined kernel image.
---
Makefile.am | 9 +++++++++
configure.ac | 22 ++++++++++++++++++++++
2 files changed, 31 insertions(+)
diff --git a/Makefile.am b/Makefile.am
index dee6aa6d0..1c080b6ce 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -786,6 +786,15 @@ endif
TESTS = $(unit_tests)
AM_TESTS_ENVIRONMENT = MALLOC_CHECK_=3 MALLOC_PERTURB_=69
+check-functional: all
+ $(srcdir)/test/test-functional -v --kernel="$(FUNCTIONAL_TESTING_KERNEL)" \
+ --bluez-build-dir="$(top_builddir)" --bluez-src-dir="$(srcdir)" \
+ $(srcdir)/test
+
+if FUNCTIONAL_TESTING
+check: check-functional
+endif
+
if DBUS_RUN_SESSION
AM_TESTS_ENVIRONMENT += dbus-run-session --
endif
diff --git a/configure.ac b/configure.ac
index 3bc1f5c44..8a94cb294 100644
--- a/configure.ac
+++ b/configure.ac
@@ -414,6 +414,28 @@ if (test "${enable_testing}" = "yes"); then
#include <linux/net_tstamp.h>]])
fi
+AC_ARG_ENABLE(functional-testing, AS_HELP_STRING([--enable-functional-testing],
+ [enable functional testing tools]),
+ [enable_functional_testing=yes; functional_testing_kernel=${enableval}],
+ [enable_functional_testing=no])
+AM_CONDITIONAL(FUNCTIONAL_TESTING, test "${enable_functional_testing}" = "yes")
+AC_ARG_VAR(FUNCTIONAL_TESTING_KERNEL, [vmlinux image to use for functional testing])
+FUNCTIONAL_TESTING_KERNEL=${functional_testing_kernel}
+
+if (test "${enable_functional_testing}" = "yes"); then
+ if (test "${enable_client}" = "no" || \
+ test "${enable_tools}" != "yes" || \
+ test "${enable_testing}" != "yes"); then
+ AC_MSG_ERROR([--enable-functional-testing requires --enable-client --enable-tools --enable-testing])
+ fi
+ AC_MSG_CHECKING([pytest and dependencies])
+ python3 -m pip install --dry-run --no-index -r "${srcdir}/test/functional/requirements.txt" >/dev/null
+ if (test "$?" != "0"); then
+ AC_MSG_ERROR([pytest or dependencies missing])
+ fi
+ AC_MSG_RESULT([ok])
+fi
+
AC_ARG_ENABLE(experimental, AS_HELP_STRING([--enable-experimental],
[enable experimental tools]),
[enable_experimental=${enableval}])
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 16/20] test: functional: impose Python code formatting
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (14 preceding siblings ...)
2026-03-22 21:31 ` [PATCH BlueZ v3 15/20] build: add functional testing target Pauli Virtanen
@ 2026-03-22 21:31 ` Pauli Virtanen
2026-03-22 21:31 ` [PATCH BlueZ v3 17/20] test: functional: add option for building kernel image first Pauli Virtanen
` (3 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:31 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Check Python code formatting of the functional test suite.
---
doc/test-functional.rst | 3 +++
test/functional/test_tests.py | 23 +++++++++++++++++++++++
2 files changed, 26 insertions(+)
create mode 100644 test/functional/test_tests.py
diff --git a/doc/test-functional.rst b/doc/test-functional.rst
index 8c392ff73..ef6147a6a 100644
--- a/doc/test-functional.rst
+++ b/doc/test-functional.rst
@@ -271,6 +271,9 @@ The functional tests are written in files (test modules) names
`test/functional/test_*.py`. They are written using standard Pytest
style. See https://docs.pytest.org/en/stable/getting-started.html
+Use `Black <https://black.readthedocs.io/en/stable/>`__ to autoformat
+Python test code.
+
Example: Virtual machines
-------------------------
diff --git a/test/functional/test_tests.py b/test/functional/test_tests.py
new file mode 100644
index 000000000..561b04703
--- /dev/null
+++ b/test/functional/test_tests.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+Tests for the test suite itself
+"""
+import sys
+import subprocess
+import warnings
+from pathlib import Path
+
+import pytest
+
+
+def test_formatting():
+ pytest.importorskip("black")
+
+ result = subprocess.run(
+ [sys.executable, "-mblack", "--check", "--diff", "-q", Path(__file__).parent],
+ stdout=subprocess.PIPE,
+ encoding="utf-8",
+ )
+ if result.returncode != 0:
+ warnings.warn(f"Formatting incorrect:\n{result.stdout}")
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 17/20] test: functional: add option for building kernel image first
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (15 preceding siblings ...)
2026-03-22 21:31 ` [PATCH BlueZ v3 16/20] test: functional: impose Python code formatting Pauli Virtanen
@ 2026-03-22 21:31 ` Pauli Virtanen
2026-03-22 21:31 ` [PATCH BlueZ v3 18/20] test: functional: add custom Agent1 implementation for testing Pauli Virtanen
` (2 subsequent siblings)
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:31 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Add convenience option test/test-functional --kernel-build to build a
kernel image first.
---
doc/test-functional.rst | 21 +++++++++-
test/pytest.ini | 4 ++
test/pytest_bluez/build_kernel.py | 43 ++++++++++++++++++++
test/pytest_bluez/plugin.py | 65 ++++++++++++++++++++++++++++++-
4 files changed, 131 insertions(+), 2 deletions(-)
create mode 100644 test/pytest_bluez/build_kernel.py
diff --git a/doc/test-functional.rst b/doc/test-functional.rst
index ef6147a6a..a03d2e461 100644
--- a/doc/test-functional.rst
+++ b/doc/test-functional.rst
@@ -11,6 +11,15 @@ DESCRIPTION
kernel using multiple virtual machine environments, connected by real
or virtual controllers.
+EXAMPLE
+=======
+
+.. code-block::
+
+ $ ./configure --enable-functional-testing --enable-testing --enable-tools
+ $ make -j8
+ $ test/test-functional --kernel-build -v
+
OPTIONS
=======
@@ -50,6 +59,12 @@ The following additional options apply:
:--list: Output brief lists of existing tests.
+:--kernel-build=no/use/auto/force: Build a suitable kernel image from source.
+
+:--kernel-upstream=<GIT_URL>: URL for Git clone of kernel sources.
+
+:--kernel-branch=<GIT_BRANCH>: Git branch to build from.
+
Tests that require kernel image or USB controllers are skipped if none
are available. Normally, tests use `btvirt`.
@@ -102,7 +117,11 @@ Kernel
------
The **test-functional(1)** tool requires a kernel image with similar
-config as **test-runner(1)**. Simplest setup is
+config as **test-runner(1)**. If given `--kernel-build` option, a
+suitable image is built from sources downloaded under
+`test/.pytest_cache`.
+
+Simplest setup is
.. code-block::
diff --git a/test/pytest.ini b/test/pytest.ini
index e77d7e4ee..b99225df2 100644
--- a/test/pytest.ini
+++ b/test/pytest.ini
@@ -12,3 +12,7 @@ addopts =
# Default timeout
vm_timeout = 30
+
+# Default sources for kernel-build when requested
+kernel_upstream = https://git.kernel.org/pub/scm/linux/kernel/git/bluetooth/bluetooth-next.git/
+kernel_branch = master
diff --git a/test/pytest_bluez/build_kernel.py b/test/pytest_bluez/build_kernel.py
new file mode 100644
index 000000000..7aebb918a
--- /dev/null
+++ b/test/pytest_bluez/build_kernel.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+import subprocess
+import shutil
+import os
+from pathlib import Path
+
+from . import utils
+
+
+def run(*cmd):
+ print("\n$", utils.quoted(cmd))
+ subprocess.run(cmd, check=True)
+
+
+def build_kernel(base_dir, upstream, branch):
+ src_dir = utils.bluez_src_dir()
+ if src_dir is None:
+ raise ValueError("Can't find BlueZ source directory")
+
+ tester_config = src_dir / "doc" / "tester.config"
+
+ base_dir = Path(base_dir)
+ repo = base_dir / "linux"
+
+ if not repo.exists():
+ run("git", "clone", "--depth", "1", upstream, str(repo))
+
+ run("git", "-C", str(repo), "remote", "set-url", "origin", upstream)
+ run("git", "-C", str(repo), "fetch", "--depth", "1", "origin", branch)
+ run("git", "-C", str(repo), "reset", "--hard", "FETCH_HEAD")
+ run("git", "-C", str(repo), "clean", "-f", "-d", "-x")
+
+ config = repo / ".config"
+ shutil.copyfile(tester_config, config)
+
+ ncpu = os.cpu_count()
+
+ run("make", "-C", str(repo), "olddefconfig")
+ run("make", "-C", str(repo), f"-j{ncpu}")
+
+ kernel = repo / "arch" / "x86" / "boot" / "bzImage"
+ return kernel
diff --git a/test/pytest_bluez/plugin.py b/test/pytest_bluez/plugin.py
index 2badcb3be..02db8e329 100644
--- a/test/pytest_bluez/plugin.py
+++ b/test/pytest_bluez/plugin.py
@@ -10,7 +10,7 @@ from pathlib import Path
import pytest
-from . import utils, env
+from . import utils, env, build_kernel
from .btmon import Btmon
@@ -124,6 +124,37 @@ def pytest_addoption(parser):
default="1048576",
)
+ # Kernel build
+ group.addoption(
+ "--kernel-build",
+ action="store",
+ choices=("no", "use", "auto", "force"),
+ nargs="?",
+ default="use",
+ const="auto",
+ help="Build and cache a suitable kernel image if none given (no/use/auto/force)",
+ )
+ group.addoption(
+ "--kernel-upstream",
+ action="store",
+ default=None,
+ help="For building kernels: kernel upstream Git url",
+ )
+ group.addoption(
+ "--kernel-branch",
+ action="store",
+ default=None,
+ help="For building kernels: kernel upstream Git branch",
+ )
+
+ parser.addini(
+ "kernel_upstream", "Kernel upstream Git url to use for building kernels"
+ )
+ parser.addini(
+ "kernel_branch",
+ "Kernel upstream Git branch / commit to use for building custom kernel",
+ )
+
def pytest_configure(config):
if config.option.list:
@@ -153,6 +184,38 @@ def pytest_configure(config):
level=config.getini("log_file_level"),
)
+ if config.option.kernel_build == "force":
+ config.option.kernel = _build_kernel(config)
+ elif (
+ config.option.kernel_build in ("auto", "use")
+ and config.option.kernel is None
+ and not os.environ.get("FUNCTIONAL_TESTING_KERNEL", None)
+ ):
+ cache_path = config.cache.mkdir("kernel")
+ kernel = cache_path / "bzImage"
+
+ if kernel.is_file():
+ config.option.kernel = kernel
+ elif config.option.kernel_build == "auto":
+ config.option.kernel = _build_kernel(config)
+
+
+def _build_kernel(config):
+ cache_path = config.cache.mkdir("kernel")
+ kernel = cache_path / "bzImage"
+
+ upstream = config.getoption("kernel_upstream") or config.getini("kernel_upstream")
+ branch = config.getoption("kernel_branch") or config.getini("kernel_branch")
+
+ capturemanager = config.pluginmanager.getplugin("capturemanager")
+ with capturemanager.global_and_fixture_disabled():
+ print(f"\n\n=== Building kernel in {cache_path} ===\n")
+ new_kernel = build_kernel.build_kernel(cache_path, upstream, branch)
+ print(f"\n\n=== Kernel build done ===\n")
+
+ os.rename(new_kernel, kernel)
+ return kernel
+
COLLECT_ERRORS = []
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 18/20] test: functional: add custom Agent1 implementation for testing
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (16 preceding siblings ...)
2026-03-22 21:31 ` [PATCH BlueZ v3 17/20] test: functional: add option for building kernel image first Pauli Virtanen
@ 2026-03-22 21:31 ` Pauli Virtanen
2026-03-22 21:31 ` [PATCH BlueZ v3 19/20] test: functional: warn on kernel warning/bug messages Pauli Virtanen
2026-03-22 21:31 ` [PATCH BlueZ v3 20/20] test: functional: add basic obex file transfer tests Pauli Virtanen
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:31 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Enable more precise testing of the Agent1 interface, and allow doing
operations like pairing without bluetoothctl.
---
doc/test-functional.rst | 137 +++++++++++++
test/functional/test_agent.py | 46 +++++
test/pytest_bluez/__init__.py | 1 +
test/pytest_bluez/agent.py | 371 ++++++++++++++++++++++++++++++++++
4 files changed, 555 insertions(+)
create mode 100644 test/functional/test_agent.py
create mode 100644 test/pytest_bluez/agent.py
diff --git a/doc/test-functional.rst b/doc/test-functional.rst
index a03d2e461..48352b96f 100644
--- a/doc/test-functional.rst
+++ b/doc/test-functional.rst
@@ -729,6 +729,143 @@ Base class for host plugins. See also example above.
"""VM-side teardown"""
pass
+Agent
+-----
+
+DBus org.bluez.Agent1 test implementation.
+
+.. code-block::
+
+ class Agent(env.HostPlugin):
+ """
+ Host plugin providing org.bluez.Agent1 test implementation.
+
+ Asynchronous events are handled via expect().
+
+ Example:
+
+ host.agent.device_method(host1.bdaddr, "Pair")
+ event = host.agent.expect("org.bluez.Agent1.RequestConfirmation")
+ assert event.passkey == 1234
+ host.agent.reply()
+ """
+
+ depends = [Bluetoothd()]
+ name = "agent"
+
+ def __init__(self, capability="KeyboardDisplay", path="/agent"):
+
+ def has_device(self, address):
+ """
+ Return True if device with given address exists
+ """
+
+ def device_method(self, address, method, *a, **kw):
+ """
+ Call given org.bluez.Device1 DBus method
+
+ Args:
+ address (str): bdaddr of target device
+ method (str): name of DBus method, without interface prefix
+ *a, **kw: argument passed to the DBus method call
+
+ Events:
+ AgentEvent(kind="org.bluez.Device1.{method}:reply")
+ """
+
+ def adapter_method(self, method, *a, **kw):
+ """
+ Call given org.bluez.Adapter1 DBus method
+
+ Args:
+ method (str): name of DBus method, without interface prefix
+ *a, **kw: argument passed to the DBus method call
+
+ Events:
+ AgentEvent(kind="org.bluez.Adapter1.{method}")
+ """
+
+ def adapter_set(self, key, value):
+ """
+ Set given org.bluez.Adapter1 property
+ """
+
+ def adapter_get(self, key):
+ """
+ Get given org.bluez.Adapter1 property
+ """
+
+ def get_event(self, block=True):
+ """
+ Get most recent pending AgentEvent, blocking optional
+ """
+
+ def expect(self, kinds):
+ """
+ Get most recent pending AgentEvent and assert its kind
+
+ Returns:
+ event (AgentEvent)
+ """
+
+ def reply(self, *value):
+ """
+ Provide DBus reply to the most recent pending AgentEvent
+
+ Arguments:
+ *value: DBus reply return values
+ """
+
+ def reply_error(self, err=None):
+ """
+ Provide DBus error reply to the most recent pending AgentEvent
+
+ Arguments:
+ err (dbus.DBusException): DBus error. Default: org.bluez.Error.Rejected
+ """
+
+.. code-block::
+
+ class Event:
+ """
+ Asynchronous event
+
+ Properties:
+ kind (str): event kind
+ info (dict): event properties (also available as attributes)
+ """
+
+.. code-block::
+
+ class EventPluginMixin:
+ """
+ Simple expect() / reply() pattern for handing async events in
+ host plugins.
+ """
+
+.. code-block::
+
+ def dbus_service_event_method(
+ interface, name, args=(), in_signature="", out_signature="", sync=True
+ ):
+ """
+ dbus.service.method that pushes Event instances to self.events
+
+ Example:
+
+ class AgentObject(dbus.service.Object):
+ @utils.mainloop_assert
+ def __init__(self, bus, path, events):
+ self.events = events
+ super().__init__(bus, path)
+
+ AuthorizeService = dbus_service_event_method(
+ "org.bluez.Agent1",
+ "AuthorizeService", ("device", "uuid"), "os", sync=False
+ )
+
+ """
+
Bdaddr
------
diff --git a/test/functional/test_agent.py b/test/functional/test_agent.py
new file mode 100644
index 000000000..c548469d6
--- /dev/null
+++ b/test/functional/test_agent.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+Tests for bluetoothctl using VM instances
+"""
+import sys
+import re
+import pytest
+import subprocess
+import tempfile
+
+import time
+import logging
+
+
+from pytest_bluez import host_config, Agent, wait_until
+
+pytestmark = [pytest.mark.vm]
+
+
+@host_config([Agent()], [Agent()])
+@pytest.mark.parametrize("success", [True, False], ids=["accept", "reject"])
+def test_agent_pair_bredr(hosts, success):
+ host0, host1 = hosts
+
+ host0.agent.adapter_method("StartDiscovery")
+ host0.agent.expect("org.bluez.Adapter1.StartDiscovery:reply")
+
+ host1.agent.adapter_set("Pairable", True)
+ host1.agent.adapter_set("Discoverable", True)
+
+ wait_until(host0.agent.has_device, host1.bdaddr)
+
+ host0.agent.device_method(host1.bdaddr, "Pair")
+
+ confirm_0 = host0.agent.expect("org.bluez.Agent1.RequestConfirmation")
+ confirm_1 = host1.agent.expect("org.bluez.Agent1.RequestConfirmation")
+ assert confirm_0.passkey == confirm_1.passkey
+ host0.agent.reply()
+
+ if success:
+ host1.agent.reply()
+ host0.agent.expect("org.bluez.Device1.Pair:reply")
+ else:
+ host1.agent.reply_error()
+ host0.agent.expect("org.bluez.Device1.Pair:error")
diff --git a/test/pytest_bluez/__init__.py b/test/pytest_bluez/__init__.py
index 9e967d472..cb0629d45 100644
--- a/test/pytest_bluez/__init__.py
+++ b/test/pytest_bluez/__init__.py
@@ -7,5 +7,6 @@ from .env import *
from .utils import *
from .host_plugins import *
from .btmon import *
+from .agent import *
from .plugin import *
diff --git a/test/pytest_bluez/agent.py b/test/pytest_bluez/agent.py
new file mode 100644
index 000000000..5a65d6bae
--- /dev/null
+++ b/test/pytest_bluez/agent.py
@@ -0,0 +1,371 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+VM host plugins
+"""
+import os
+import sys
+import subprocess
+import collections
+import logging
+import tempfile
+import time
+import shutil
+import queue
+from pathlib import Path
+
+import dbus
+import dbus.service
+
+from . import env, utils
+from .host_plugins import Bluetoothd
+
+__all__ = ["Agent", "Event", "EventPluginMixin", "dbus_service_event_method"]
+
+BUS_NAME = "org.bluez"
+AGENT_INTERFACE = "org.bluez.Agent1"
+AGENT_MANAGER_INTERFACE = "org.bluez.AgentManager1"
+ADAPTER_INTERFACE = "org.bluez.Adapter1"
+DEVICE_INTERFACE = "org.bluez.Device1"
+PROPS_INTERFACE = "org.freedesktop.DBus.Properties"
+
+log = logging.getLogger("agent")
+
+
+class Rejected(dbus.DBusException):
+ _dbus_error_name = "org.bluez.Error.Rejected"
+
+
+class Event:
+ """
+ Asynchronous event.
+
+ Properties:
+ kind (str): event kind
+ info (dict): event properties (also available as attributes)
+ """
+
+ def __init__(
+ self, kind, reply_cb=None, error_cb=None, reply_type=None, info=None, **kw
+ ):
+ if info is None:
+ info = {}
+ info.update(kw)
+ self.reply_cb = reply_cb
+ self.error_cb = error_cb
+ self.kind = kind
+ self.reply_type = reply_type
+ self.info = info
+
+ def __getattr__(self, name):
+ try:
+ return self.__dict__["info"][name]
+ except KeyError:
+ raise AttributeError(name)
+
+ def __getstate__(self):
+ return dict(self.__dict__, reply_cb=None, error_cb=None)
+
+
+class EventPluginMixin:
+ """
+ Simple expect() / reply() pattern for handing async events in
+ host plugins.
+
+ """
+
+ def setup(self, impl):
+ self.events = queue.SimpleQueue()
+ self.cur_event = None
+
+ def get_event(self, block=True):
+ """
+ Get most recent pending Event, blocking optional
+ """
+ if self.cur_event is not None:
+ return self.cur_event
+
+ try:
+ self.cur_event = self.events.get(block=block)
+ return self.cur_event
+ except queue.Empty:
+ return None
+
+ def expect(self, kinds):
+ """
+ Get most recent pending Event and assert its kind
+
+ Returns:
+ event (Event)
+ """
+ if isinstance(kinds, str):
+ kinds = (kinds,)
+ kinds = tuple(kinds)
+
+ event = self.get_event()
+ if event.kind not in kinds:
+ raise AssertionError(f"Got {event.kind=}, expected {kinds=}")
+ if event.reply_cb is None:
+ self.cur_event = None
+
+ log.info(f"match {event.kind=}")
+ return event
+
+ @utils.mainloop_wrap
+ def reply(self, *value):
+ """
+ Provide DBus reply to the most recent pending Event
+
+ Arguments:
+ *value: DBus reply return values
+ """
+ if len(value) == 1 and isinstance(value[0], Exception):
+ self.cur_event.error_cb(value[0])
+ else:
+ if self.cur_event.reply_type is not None:
+ value = self.cur_event.reply_type(*value)
+ self.cur_event.reply_cb(*value)
+ self.cur_event = None
+
+ def reply_error(self, err=None):
+ """
+ Provide DBus error reply to the most recent pending Event
+
+ Arguments:
+ err (dbus.DBusException): DBus error. Default: org.bluez.Error.Rejected
+ """
+ if err is None:
+ err = Rejected()
+ self.reply(err)
+
+ @utils.mainloop_assert
+ def _object_method(self, obj, method, *a, **kw):
+ iface = obj.dbus_interface
+
+ reply_handler = kw.pop("reply_handler", None)
+ error_handler = kw.pop("error_handler", None)
+
+ def reply(*values):
+ log.info(f"{iface}.{method} reply: {values!r}")
+ if reply_handler is not None:
+ reply_handler(*values)
+ self.events.put(Event(f"{iface}.{method}:reply", values=values))
+
+ def error(err):
+ log.info(f"{iface}.{method} error: {err!r}")
+ if error_handler is not None:
+ error_handler(err)
+ self.events.put(Event(f"{iface}.{method}:error", error=err))
+
+ getattr(obj, method)(*a, **kw, reply_handler=reply, error_handler=error)
+
+
+class Agent(env.HostPlugin, EventPluginMixin):
+ """
+ Host plugin providing org.bluez.Agent1 test implementation.
+
+ Asynchronous events are handled via expect().
+
+ Example:
+
+ host.agent.device_method(host1.bdaddr, "Pair")
+ event = host.agent.expect("org.bluez.Agent1.RequestConfirmation")
+ assert event.passkey == 1234
+ host.agent.reply()
+ """
+
+ depends = [Bluetoothd()]
+ name = "agent"
+
+ def __init__(self, capability="KeyboardDisplay", path="/agent"):
+ self.capability = capability
+ self.path = path
+
+ @utils.mainloop_wrap
+ def setup(self, impl):
+ EventPluginMixin.setup(self, impl)
+
+ self.bus = dbus.SystemBus(private=True)
+ self.bus.set_exit_on_disconnect(False)
+
+ self.agent = AgentObject(self.bus, self.path, self.events)
+
+ bluez = self.bus.get_object(BUS_NAME, "/org/bluez")
+ self.manager = dbus.Interface(bluez, AGENT_MANAGER_INTERFACE)
+ self.manager.RegisterAgent(self.path, self.capability)
+
+ log.info("Agent registered")
+
+ self.manager.RequestDefaultAgent(self.path)
+
+ @utils.mainloop_wrap
+ def teardown(self):
+ self.manager.UnregisterAgent(self.path)
+ log.info("Agent unregistered")
+
+ @utils.mainloop_wrap
+ def has_device(self, address):
+ """
+ Return True if device with given address exists
+ """
+ try:
+ self._find_device(address)
+ return True
+ except ValueError:
+ return False
+
+ @utils.mainloop_wrap
+ def device_method(self, address, method, *a, **kw):
+ """
+ Call given org.bluez.Device1 DBus method
+
+ Args:
+ address (str): bdaddr of target device
+ method (str): name of DBus method, without interface prefix
+ *a, **kw: argument passed to the DBus method call
+
+ Events:
+ Event(kind="org.bluez.Device1.{method}:reply")
+ """
+ device = self._find_device(address)
+ self._object_method(device, method, *a, **kw)
+
+ @utils.mainloop_wrap
+ def adapter_method(self, method, *a, **kw):
+ """
+ Call given org.bluez.Adapter1 DBus method
+
+ Args:
+ method (str): name of DBus method, without interface prefix
+ *a, **kw: argument passed to the DBus method call
+
+ Events:
+ Event(kind="org.bluez.Adapter1.{method}")
+ """
+ adapter = dbus.Interface(
+ self.bus.get_object(BUS_NAME, "/org/bluez/hci0"), ADAPTER_INTERFACE
+ )
+ self._object_method(adapter, method, *a, **kw)
+
+ @utils.mainloop_wrap
+ def adapter_set(self, key, value):
+ """
+ Set given org.bluez.Adapter1 property
+ """
+ adapter = dbus.Interface(
+ self.bus.get_object(BUS_NAME, "/org/bluez/hci0"), PROPS_INTERFACE
+ )
+ adapter.Set(ADAPTER_INTERFACE, key, value)
+
+ @utils.mainloop_wrap
+ def adapter_get(self, key):
+ """
+ Get given org.bluez.Adapter1 property
+ """
+ adapter = dbus.Interface(
+ self.bus.get_object(BUS_NAME, "/org/bluez/hci0"), PROPS_INTERFACE
+ )
+ return adapter.Get(ADAPTER_INTERFACE, key)
+
+ @utils.mainloop_assert
+ def _find_device(self, address):
+ manager = dbus.Interface(
+ self.bus.get_object(BUS_NAME, "/"), "org.freedesktop.DBus.ObjectManager"
+ )
+ objects = manager.GetManagedObjects()
+
+ for path, ifaces in objects.items():
+ device = ifaces.get(DEVICE_INTERFACE)
+ if device is None:
+ continue
+ if device["Address"].lower() == address.lower():
+ return dbus.Interface(
+ self.bus.get_object(BUS_NAME, path), DEVICE_INTERFACE
+ )
+ else:
+ raise ValueError("Device {address=} not found")
+
+
+def dbus_service_event_method(
+ interface, name, args=(), in_signature="", out_signature="", sync=True
+):
+ """
+ dbus.service.method that pushes Event instances to self.events
+
+ Example:
+
+ class AgentObject(dbus.service.Object):
+ @utils.mainloop_assert
+ def __init__(self, bus, path, events):
+ self.events = events
+ super().__init__(bus, path)
+
+ AuthorizeService = dbus_service_event_method(
+ "org.bluez.Agent1",
+ "AuthorizeService", ("device", "uuid"), "os", sync=False
+ )
+
+ """
+
+ reply_type = {"": "None", "s": "str", "u": "dbus.UInt32"}[out_signature]
+
+ if not sync:
+ cb_args = ("_reply", "_error")
+ kw = dict(
+ in_signature=in_signature,
+ out_signature=out_signature,
+ async_callbacks=("_reply", "_error"),
+ )
+ else:
+ cb_args = ()
+ kw = dict(in_signature=in_signature, out_signature=out_signature)
+ assert not out_signature
+
+ args_str = ", ".join(args)
+ if args_str:
+ args_str = ", " + args_str
+
+ cb_args_str = ", ".join(cb_args)
+ if cb_args:
+ cb_args_str = ", " + cb_args_str
+
+ args_dict = ", ".join(f"{k}={k}" for k in args)
+
+ method_str = f"""def {name}(self{args_str}{cb_args_str}):
+ info = dict({args_dict})
+ log.info(f"{interface}.{name} {{info}}")
+ self.events.put(Event("{interface}.{name}"{cb_args_str}, reply_type={reply_type}, info=info))
+ """
+
+ ns = dict(dbus=dbus, Event=Event, log=log)
+ exec(method_str, ns)
+ return dbus.service.method(interface, **kw)(utils.mainloop_assert(ns[name]))
+
+
+def agent_method(*a, **kw):
+ return dbus_service_event_method(AGENT_INTERFACE, *a, **kw)
+
+
+class AgentObject(dbus.service.Object):
+ @utils.mainloop_assert
+ def __init__(self, bus, path, events):
+ self.events = events
+ super().__init__(bus, path)
+
+ Release = agent_method("Release")
+ AuthorizeService = agent_method(
+ "AuthorizeService", ("device", "uuid"), "os", sync=False
+ )
+ RequestPinCode = agent_method("RequestPinCode", ("device",), "o", "s", sync=False)
+ RequestPasskey = agent_method("RequestPasskey", ("device",), "o", "u", sync=False)
+ RequestConfirmation = agent_method(
+ "RequestConfirmation", ("device", "passkey"), "ou", sync=False
+ )
+ RequestAuthorization = agent_method(
+ "RequestAuthorization", ("device",), "o", sync=False
+ )
+ DisplayPasskey = agent_method(
+ "DisplayPasskey", ("device", "passkey", "entered"), "ouq"
+ )
+ DisplayPinCode = agent_method("DisplayPinCode", ("device", "pincode"), "os")
+ Cancel = agent_method("Cancel")
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 19/20] test: functional: warn on kernel warning/bug messages
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (17 preceding siblings ...)
2026-03-22 21:31 ` [PATCH BlueZ v3 18/20] test: functional: add custom Agent1 implementation for testing Pauli Virtanen
@ 2026-03-22 21:31 ` Pauli Virtanen
2026-03-22 21:31 ` [PATCH BlueZ v3 20/20] test: functional: add basic obex file transfer tests Pauli Virtanen
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:31 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Emit warnings if there is a WARNING/BUG message from kernel.
---
test/pytest_bluez/env.py | 25 ++++++++++++++++++++++---
1 file changed, 22 insertions(+), 3 deletions(-)
diff --git a/test/pytest_bluez/env.py b/test/pytest_bluez/env.py
index cb920e714..eb7a6069f 100644
--- a/test/pytest_bluez/env.py
+++ b/test/pytest_bluez/env.py
@@ -581,8 +581,8 @@ class Environment:
errors = "\n".join(errors)
raise RuntimeError(f"Errors closing hosts:\n{errors}")
- def _add_log(self, *a, **kw):
- f = utils.LogStream(*a, **kw)
+ def _add_log(self, *a, cls=utils.LogStream, **kw):
+ f = cls(*a, **kw)
self.log_streams.append(f)
return f.stream
@@ -743,7 +743,7 @@ class Environment:
host_names.append(f"host.{ENV_INDEX}.{idx}")
- logger = self._add_log(host_names[-1])
+ logger = self._add_log(host_names[-1], cls=_HostLogStream)
self.jobs.append(Popen(cmd, stdout=logger, stderr=logger, stdin=DEVNULL))
# Start log reader
@@ -782,3 +782,22 @@ class Environment:
def __exit__(self, type, value, tb):
self.stop()
+
+
+class _HostLogStream(utils.LogStream):
+ """
+ Log streams that parses kernel BUG:/WARNING:
+ """
+
+ def __init__(self, name):
+ super().__init__(name)
+ self._warn_re = re.compile(
+ rb"^(BUG:|WARNING:|general protection fault|Kernel panic)"
+ )
+
+ def _do_log(self, line, anc):
+ super()._do_log(line, anc)
+
+ if self._warn_re.match(line):
+ fmt_line = line.decode("utf-8", errors="surrogateescape")
+ warnings.warn(fmt_line)
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread* [PATCH BlueZ v3 20/20] test: functional: add basic obex file transfer tests
2026-03-22 21:29 [PATCH BlueZ v3 00/20] Functional/integration testing Pauli Virtanen
` (18 preceding siblings ...)
2026-03-22 21:31 ` [PATCH BlueZ v3 19/20] test: functional: warn on kernel warning/bug messages Pauli Virtanen
@ 2026-03-22 21:31 ` Pauli Virtanen
19 siblings, 0 replies; 26+ messages in thread
From: Pauli Virtanen @ 2026-03-22 21:31 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Pauli Virtanen
Add tests
test/functional/test_obex.py::test_obex_ftp_get
test/functional/test_obex.py::test_obex_ftp_list
test/functional/test_obex.py::test_obexctl_list
---
test/functional/test_obex.py | 285 ++++++++++++++++++++++++++++++
test/pytest_bluez/host_plugins.py | 61 +++++++
2 files changed, 346 insertions(+)
create mode 100644 test/functional/test_obex.py
diff --git a/test/functional/test_obex.py b/test/functional/test_obex.py
new file mode 100644
index 000000000..2f408efb4
--- /dev/null
+++ b/test/functional/test_obex.py
@@ -0,0 +1,285 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+Tests for Obex
+"""
+import sys
+import os
+import re
+import pytest
+import subprocess
+import tempfile
+import time
+import logging
+import json
+import dbus
+import threading
+from pathlib import Path
+
+import pytest
+
+from pytest_bluez import (
+ HostPlugin,
+ Agent,
+ host_config,
+ find_exe,
+ Bluetoothd,
+ Bluetoothctl,
+ Obexd,
+ LogStream,
+ wait_until,
+ mainloop_wrap,
+ mainloop_assert,
+ Event,
+ EventPluginMixin,
+ dbus_service_event_method,
+ Pexpect,
+ utils,
+)
+
+pytestmark = [pytest.mark.vm]
+
+log = logging.getLogger(__name__)
+
+
+BUS_NAME = "org.bluez.obex"
+PATH = "/org/bluez/obex"
+AGENT_MANAGER_INTERFACE = "org.bluez.obex.AgentManager1"
+AGENT_INTERFACE = "org.bluez.obex.Agent1"
+CLIENT_INTERFACE = "org.bluez.obex.Client1"
+SESSION_INTERFACE = "org.bluez.obex.Session1"
+FILE_TRANSFER_INTERFACE = "org.bluez.obex.FileTransfer1"
+TRANSFER_INTERFACE = "org.bluez.obex.Transfer1"
+
+FTP_UUID = "00001106-0000-1000-8000-00805f9b34fb"
+
+
+class ObexAgent(HostPlugin, EventPluginMixin):
+ depends = [Bluetoothd()]
+ name = "obex_agent"
+
+ def __init__(self, path="/obexagent"):
+ self.path = path
+
+ @mainloop_wrap
+ def setup(self, impl):
+ EventPluginMixin.setup(self, impl)
+
+ self.bus = dbus.SessionBus()
+ self.bus.set_exit_on_disconnect(False)
+
+ self.agent = ObexAgentObject(self.bus, self.path, self.events)
+
+ bluez = self.bus.get_object(BUS_NAME, PATH)
+ self.manager = dbus.Interface(bluez, AGENT_MANAGER_INTERFACE)
+ self.manager.RegisterAgent(self.path)
+
+ log.info("Obex agent registered")
+
+ def cleanup(self):
+ path = Path("/run/obex")
+ for f in path.iterdir():
+ f.unlink()
+
+
+def agent_method(*a, **kw):
+ return dbus_service_event_method(AGENT_INTERFACE, *a, **kw)
+
+
+class ObexAgentObject(dbus.service.Object):
+ @mainloop_assert
+ def __init__(self, bus, path, events):
+ self.events = events
+ super().__init__(bus, path)
+
+ AuthorizePush = agent_method("AuthorizePush", ("path",), "o", "s", sync=False)
+ Cancel = agent_method("Cancel")
+
+
+def write_obex_file(name, content):
+ with open(f"/run/obex/{name}", "w") as f:
+ f.write(content)
+
+
+def read_file(name):
+ with open(name, "r") as f:
+ return f.read()
+
+
+#
+# Direct Obex Python client API tests
+#
+
+
+class ObexClient(HostPlugin, EventPluginMixin):
+ name = "obex"
+
+ @mainloop_wrap
+ def setup(self, impl):
+ EventPluginMixin.setup(self, impl)
+
+ self.transferred = 0
+ self.transfer_path = None
+ self.transfer_size = 0
+
+ self.bus = dbus.SessionBus()
+ self.bus.set_exit_on_disconnect(False)
+ self.log = logging.getLogger(self.name)
+ self.client = dbus.Interface(
+ self.bus.get_object(BUS_NAME, PATH), CLIENT_INTERFACE
+ )
+
+ self.bus.add_signal_receiver(
+ self.properties_changed,
+ dbus_interface="org.freedesktop.DBus.Properties",
+ signal_name="PropertiesChanged",
+ path_keyword="path",
+ )
+
+ @mainloop_wrap
+ def connect(self, bdaddr):
+ def reply(path):
+ obj = self.bus.get_object(BUS_NAME, path)
+ self.session = dbus.Interface(obj, SESSION_INTERFACE)
+ self.ftp = dbus.Interface(obj, FILE_TRANSFER_INTERFACE)
+
+ self._object_method(
+ self.client, "CreateSession", bdaddr, {"Target": "ftp"}, reply_handler=reply
+ )
+
+ @mainloop_assert
+ def properties_changed(self, interface, properties, invalidated, path):
+ if path != self.transfer_path:
+ return
+
+ if "Status" in properties and (
+ properties["Status"] == "complete" or properties["Status"] == "error"
+ ):
+ self.events.put(
+ Event(
+ f"{FILE_TRANSFER_INTERFACE}:{properties['Status']}",
+ properties=properties,
+ )
+ )
+ self.log.debug(f"Transfer {properties['Status']}")
+
+ if "Transferred" not in properties:
+ return
+
+ value = properties["Transferred"]
+ speed = (value - self.transferred) / 1000
+ self.log.debug(
+ f"Transfer progress {value}/{self.transfer_size} at {speed} kBps"
+ )
+ self.transferred = value
+
+ @mainloop_wrap
+ def ftp_list_folder(self):
+ return self.ftp.ListFolder()
+
+ @mainloop_wrap
+ def ftp_get_file(self, dst, src):
+ path, properties = self.ftp.GetFile(dst, src)
+ self.transfer_path = path
+ self.transfer_size = properties["Size"]
+ return properties["Filename"]
+
+
+@pytest.fixture
+def paired_hosts(hosts):
+ from .test_agent import test_agent_pair_bredr
+
+ if hosts[0].agent.has_device(hosts[1].bdaddr):
+ return hosts
+
+ test_agent_pair_bredr(hosts, True)
+ return hosts
+
+
+obex_host_config = host_config(
+ [Agent(), Obexd(), ObexClient(), Pexpect()],
+ [Agent(), Obexd(), ObexAgent()],
+ reuse=True,
+)
+
+
+@pytest.fixture
+def obex_hosts(paired_hosts):
+ host0, host1 = paired_hosts
+
+ if hasattr(host0, "session"):
+ return paired_hosts
+
+ host0.obex.connect(host1.bdaddr)
+
+ service = host1.agent.expect("org.bluez.Agent1.AuthorizeService")
+ assert service.uuid == FTP_UUID
+ host1.agent.reply()
+
+ host0.obex.expect("org.bluez.obex.Client1.CreateSession:reply")
+
+ yield paired_hosts
+
+ host1.obex_agent.cleanup()
+
+
+@obex_host_config
+def test_obex_ftp_list(obex_hosts):
+ host0, host1 = obex_hosts
+
+ host1.call(write_obex_file, "test", "1234")
+
+ (item,) = host0.obex.ftp_list_folder()
+ assert item["Type"] == "file"
+ assert item["Name"] == "test"
+ assert item["Size"] == 4
+
+
+@obex_host_config
+def test_obex_ftp_get(obex_hosts):
+ host0, host1 = obex_hosts
+
+ host1.call(write_obex_file, "test", "1234")
+
+ filename = host0.obex.ftp_get_file("", "test")
+ host0.obex.expect("org.bluez.obex.FileTransfer1:complete")
+ assert host0.call(read_file, filename) == "1234"
+
+
+#
+# obexctl tests
+#
+
+
+@pytest.fixture
+def obexctl(obex_hosts):
+ host0, host1 = obex_hosts
+
+ exe = find_exe("tools", "obexctl")
+ obexctl = host0.pexpect.spawn([exe])
+
+ obexctl.expect("Client /org/bluez/obex")
+ obexctl.send(f"connect {host1.bdaddr} {FTP_UUID}\n")
+
+ service = host1.agent.expect("org.bluez.Agent1.AuthorizeService")
+ assert service.uuid == FTP_UUID
+ host1.agent.reply()
+
+ obexctl.expect("Connection successful")
+ obexctl.send(f"select /org/bluez/obex/client/session1\n")
+
+ yield obexctl
+
+ obexctl.close()
+
+
+@obex_host_config
+def test_obexctl_list(obex_hosts, obexctl):
+ host0, host1 = obex_hosts
+
+ host1.call(write_obex_file, "test", "1234")
+
+ obexctl.send(f"ls\n")
+ obexctl.expect(f"Type: file")
+ obexctl.expect(f"Name: test")
+ obexctl.expect(f"Size: 4")
diff --git a/test/pytest_bluez/host_plugins.py b/test/pytest_bluez/host_plugins.py
index 3b9cf7857..b7425590e 100644
--- a/test/pytest_bluez/host_plugins.py
+++ b/test/pytest_bluez/host_plugins.py
@@ -16,6 +16,7 @@ import signal
import functools
import threading
import resource
+import shutil
from pathlib import Path
import pytest
@@ -34,6 +35,7 @@ __all__ = [
"Call",
"DbusSession",
"DbusSystem",
+ "Obexd",
"Pexpect",
"Rcvbuf",
]
@@ -316,6 +318,65 @@ class Bluetoothd(env.HostPlugin):
self.log_stream.close()
+class Obexd(env.HostPlugin):
+ """
+ Host plugin starting obexd.
+ """
+
+ name = "obexd"
+ depends = [Bluetoothd(), DbusSession()]
+
+ def __init__(self):
+ self.uuids = ("00001133-0000-1000-8000-00805f9b34fb",)
+
+ def presetup(self, config):
+ try:
+ self.exe = utils.find_exe("obexd/src", "obexd")
+ except FileNotFoundError as exc:
+ pytest.skip(reason=f"Obexd: {exc!r}")
+
+ @utils.mainloop_wrap
+ def setup(self, impl):
+ self.log = logging.getLogger(self.name)
+
+ self.path = Path("/run/obex")
+ self.path.mkdir()
+
+ cmd = [self.exe, "--nodetach", f"--root={self.path}", "-d", "*"]
+ self.log.info("Start obexd: {}".format(utils.quoted(cmd)))
+
+ self.log_stream = utils.LogStream("obexd")
+ self.job = subprocess.Popen(
+ cmd,
+ stdin=subprocess.DEVNULL,
+ stdout=self.log_stream.stream,
+ stderr=subprocess.STDOUT,
+ )
+
+ # Wait for the service
+ self.log.info("Wait for obexd...")
+ bus = dbus.SystemBus()
+ bus.set_exit_on_disconnect(False)
+ adapter = dbus.Interface(
+ bus.get_object("org.bluez", "/org/bluez/hci0"),
+ "org.freedesktop.DBus.Properties",
+ )
+
+ def cond():
+ uuids = [str(uuid) for uuid in adapter.Get("org.bluez.Adapter1", "UUIDs")]
+ return all(uuid in uuids for uuid in self.uuids)
+
+ utils.wait_until(cond)
+
+ self.log.info("Obexd ready")
+
+ def teardown(self):
+ self.log.info("Stop obexd")
+ self.job.terminate()
+ self.log_stream.close()
+ shutil.rmtree(self.path)
+
+
class Pexpect(env.HostPlugin):
"""
Host plugin for starting and controlling processes with pexpect.
--
2.53.0
^ permalink raw reply related [flat|nested] 26+ messages in thread