Funpack2-6:NRF7002-DK——蓝牙键盘鼠标复合设备
基于Nordic nRF7002-DK,实现一个鼠标键盘复合设备
标签
嵌入式系统
Funpack活动
USB
蓝牙
EPTmachine
更新2023-10-08
685

本期Funpack活动的板卡是Nordic的nRF7002-DK,我选择的任务是任务一,任务目标为:

使用板卡的蓝牙连接,设计一个蓝牙鼠标+键盘复合设备,按键1作为鼠标点击,按键2作为键盘输入按下时输入“eetree”字符,电脑开启大写锁定时,板卡的LED亮起。项目的实现可以分为以下几部分:

  1. 蓝牙连接部分;
  2. 按键输入检测和LED控制;
  3. USB HID描述符初始化、发送数据封装以及接收数据解析;

开发环境搭建

参考官方的DevAcademy上的指引,安装了nrf-command-line-tools和nrfconnect-for desktop,在nrfconnect-for desktop中配置了开发所需要使用的工具链。在VSCode中打开的操作界面如下图所示。

Fm8GIY80BKrVBv_aKnpv_10iIU6F

在左侧的菜单栏中可以快捷地对工程进行管理,搭配官方地入门教程可以很快地创建和运行示例程序。

工程整体流程

本项目的工作流程分为初始化过程和响应外部事件两个部分,初始化过程执行板上的按键、LED以及蓝牙的初始化、回调函数注册等操作,并初始化工作队列用于后续响应外部事件。初始化过程的流程如图所示。

FlsK_jX3l742YLgWKoNMTle2D7wM

相关的代码如下。

处理蓝牙连接事件
static struct k_work pairing_work;

// 处理鼠标事件
static struct k_work hids_work;
static void configure_gpio(void)
{
	int err;

	err = dk_buttons_init(button_changed);
	if (err)
	{
		printk("Cannot init buttons (err: %d)\n", err);
	}

	err = dk_leds_init();
	if (err)
	{
		printk("Cannot init LEDs (err: %d)\n", err);
	}
}
int main(void)
{
	int err;
	int blink_status = 0;

	printk("Starting Bluetooth Peripheral HIDS keyboard example\n");
        //配置外部IO
	configure_gpio();

	err = bt_conn_auth_cb_register(&conn_auth_callbacks);
	if (err)
	{
		printk("Failed to register authorization callbacks.\n");
		return 0;
	}

	err = bt_conn_auth_info_cb_register(&conn_auth_info_callbacks);
	if (err)
	{
		printk("Failed to register authorization info callbacks.\n");
		return 0;
	}

	hid_init();

	err = bt_enable(NULL);
	if (err)
	{
		printk("Bluetooth init failed (err %d)\n", err);
		return 0;
	}

	printk("Bluetooth initialized\n");

	if (IS_ENABLED(CONFIG_SETTINGS))
	{
		settings_load();
	}

#if CONFIG_NFC_OOB_PAIRING
	k_work_init(&adv_work, delayed_advertising_start);
	app_nfc_init();
#else
	advertising_start();
#endif

	// 初始化鼠标处理线程
	k_work_init(&hids_work, mouse_handler);
	// 初始化蓝牙配对处理线程
	k_work_init(&pairing_work, pairing_process);

	for (;;)
	{
		if (is_adv)
		{
			dk_set_led(ADV_STATUS_LED, (++blink_status) % 2);
		}
		else
		{
			dk_set_led_off(ADV_STATUS_LED);
		}
		k_sleep(K_MSEC(ADV_LED_BLINK_INTERVAL));
		/* Battery level simulation */
		bas_notify();
	}
}

响应外部事件主要为响应板上的按键以及主机向设备(开发板)发送通讯数据等事件,根据不同的事件执行不同的动作,响应的事件有按键1、按键2以及设备接收到电脑端发送过来的按键信息等事件,针对不同的事件,程序采取响应的处理来实现对应的功能,相应的代码在后续的章节中介绍。

Nordic的开发工具使用prj.conf文件来对系统的功能和资源进行配置,本项目基于Nordic提供的BlueTooth示例工程中的peripheral_hids_keyboard工具进行修改而来。在工程目录中的prj.conf中添加以下配置,用来调整工程中的资源配置。

uEf5P8H9zg2SZj7BoEAAAAASUVORK5CYII=

在构建配置中选择项目中使用到的板卡以及设置工程使用到的配置文件,这样,编译出来的程序就可以在板卡中运行了。

ZzlAjctgQAAMDTpP8PtqCDUEt6EHMAAAAASUVORK5CYII=

蓝牙连接部分

示例工程中给出了完整的蓝牙连接部分的代码,实现蓝牙设备的初始化、广播、电脑端连接提示灯功能。程序中相关的代码如下:

static void pairing_process(struct k_work *work)
{
	int err;
	struct pairing_data_mitm pairing_data;

	char addr[BT_ADDR_LE_STR_LEN];

	err = k_msgq_peek(&mitm_queue, &pairing_data);
	if (err)
	{
		return;
	}

	bt_addr_le_to_str(bt_conn_get_dst(pairing_data.conn),
					  addr, sizeof(addr));

	printk("Passkey for %s: %06u\n", addr, pairing_data.passkey);
	printk("Press Button 1 to confirm, Button 2 to reject.\n");
}

static void connected(struct bt_conn *conn, uint8_t err)
{
	char addr[BT_ADDR_LE_STR_LEN];

	bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));

	if (err)
	{
		printk("Failed to connect to %s (%u)\n", addr, err);
		return;
	}

	printk("Connected %s\n", addr);
	dk_set_led_on(CON_STATUS_LED);

	err = bt_hids_connected(&hids_obj, conn);

	if (err)
	{
		printk("Failed to notify HID service about connection\n");
		return;
	}

	for (size_t i = 0; i < CONFIG_BT_HIDS_MAX_CLIENT_COUNT; i++)
	{
		if (!conn_mode[i].conn)
		{
			conn_mode[i].conn = conn;
			conn_mode[i].in_boot_mode = false;
			break;
		}
	}

#if CONFIG_NFC_OOB_PAIRING == 0
	for (size_t i = 0; i < CONFIG_BT_HIDS_MAX_CLIENT_COUNT; i++)
	{
		if (!conn_mode[i].conn)
		{
			advertising_start();
			return;
		}
	}
#endif
	is_adv = false;
}

static void disconnected(struct bt_conn *conn, uint8_t reason)
{
	int err;
	bool is_any_dev_connected = false;
	char addr[BT_ADDR_LE_STR_LEN];

	bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));

	printk("Disconnected from %s (reason %u)\n", addr, reason);

	err = bt_hids_disconnected(&hids_obj, conn);

	if (err)
	{
		printk("Failed to notify HID service about disconnection\n");
	}

	for (size_t i = 0; i < CONFIG_BT_HIDS_MAX_CLIENT_COUNT; i++)
	{
		if (conn_mode[i].conn == conn)
		{
			conn_mode[i].conn = NULL;
		}
		else
		{
			if (conn_mode[i].conn)
			{
				is_any_dev_connected = true;
			}
		}
	}

	if (!is_any_dev_connected)
	{
		dk_set_led_off(CON_STATUS_LED);
	}

#if CONFIG_NFC_OOB_PAIRING
	if (is_adv)
	{
		printk("Advertising stopped after disconnect\n");
		bt_le_adv_stop();
		is_adv = false;
	}
#else
	advertising_start();
#endif
}

LED和按键驱动

LED和按键的控制引脚在板卡默认的DeviceTree中进行了定义,这里使用默认参数即可(如果需要修改,创建相应的.overlay文件来对管脚的对应关系进行修改即可)。

LED和按键初始化函数调用Nordic封装好的适用于DK板卡的函数进行初始化。代码如下:

static void configure_gpio(void)
{
	int err;

	err = dk_buttons_init(button_changed);
	if (err)
	{
		printk("Cannot init buttons (err: %d)\n", err);
	}

	err = dk_leds_init();
	if (err)
	{
		printk("Cannot init LEDs (err: %d)\n", err);
	}
}

按键事件的回调函数形式如下,用户自定义相应的回调函数用于响应按键的状态变化。

/**
 * @typedef button_handler_t
 * @brief Callback that is executed when a button state change is detected.
 *
 * @param button_state Bitmask of button states.
 * @param has_changed Bitmask that shows which buttons have changed.
 */
typedef void (*button_handler_t)(uint32_t button_state, uint32_t has_changed);

USB HID数据封装

这部分是实现该项目的核心,USB HID(Human Interface Descriptor)定义了设备在和主机设备连接时的数据传输的帧格式以及其中数据的含义。

上述使用的工程中,已经定义了蓝牙键盘的HID描述符,要添加蓝牙鼠标的功能,在此基础上添加鼠标对应的HID描述符,同时在初始化过程中添加对蓝牙鼠标的支持就可以实现蓝牙鼠标的功能;

关于USB HID描述符的定义可以参考HID 简介 - USB中文网 (usbzh.com)。相关的USB HID初始化代码以及注释如下:

static void hid_init(void)
{
	int err;
	struct bt_hids_init_param hids_init_obj = {0};
	struct bt_hids_inp_rep *hids_inp_rep;
	// static const uint8_t mouse_movement_mask[DIV_ROUND_UP(INPUT_REP_BUTTON_LEN, 8)] = {0};
	struct bt_hids_outp_feat_rep *hids_outp_rep;

	static const uint8_t report_map[] = {
		0x05,
		0x01, /* Usage Page (Generic Desktop) */
		0x09,
		0x06, /* Usage (Keyboard) */
		0xA1,
		0x01, /* Collection (Application) */

	/* Keys */
#if INPUT_REP_KEYS_REF_ID
		0x85,
		INPUT_REP_KEYS_REF_ID,
#endif
		0x05,
		0x07, /* Usage Page (Key Codes) */
		0x19,
		0xe0, /* Usage Minimum (224) */
		0x29,
		0xe7, /* Usage Maximum (231) */
		0x15,
		0x00, /* Logical Minimum (0) */
		0x25,
		0x01, /* Logical Maximum (1) */
		0x75,
		0x01, /* Report Size (1) */
		0x95,
		0x08, /* Report Count (8) */
		0x81,
		0x02, /* Input (Data, Variable, Absolute) */

		0x95,
		0x01, /* Report Count (1) */
		0x75,
		0x08, /* Report Size (8) */
		0x81,
		0x01, /* Input (Constant) reserved byte(1) */

		0x95,
		0x06, /* Report Count (6) */
		0x75,
		0x08, /* Report Size (8) */
		0x15,
		0x00, /* Logical Minimum (0) */
		0x25,
		0x65, /* Logical Maximum (101) */
		0x05,
		0x07, /* Usage Page (Key codes) */
		0x19,
		0x00, /* Usage Minimum (0) */
		0x29,
		0x65, /* Usage Maximum (101) */
		0x81,
		0x00, /* Input (Data, Array) Key array(6 bytes) */

	/* LED */
#if OUTPUT_REP_KEYS_REF_ID
		0x85,
		OUTPUT_REP_KEYS_REF_ID,
#endif
		0x95,
		0x05, /* Report Count (5) */
		0x75,
		0x01, /* Report Size (1) */
		0x05,
		0x08, /* Usage Page (Page# for LEDs) */
		0x19,
		0x01, /* Usage Minimum (1) */
		0x29,
		0x05, /* Usage Maximum (5) */
		0x91,
		0x02, /* Output (Data, Variable, Absolute), */
		/* Led report */
		0x95,
		0x01, /* Report Count (1) */
		0x75,
		0x03, /* Report Size (3) */
		0x91,
		0x01, /* Output (Data, Variable, Absolute), */
		/* Led report padding */
		0xC0, /* End Collection (Application) */

		0x05,
		0x01, /* Usage Page (Generic Desktop) */
		0x09,
		0x02, /* Usage (Mouse) */

		0xA1,
		0x01, /* Collection (Application) */
		/* Report ID 2: Mouse buttons + scroll/pan */
		0x85,
		0x02, /* Report Id 2 */
		0x09,
		0x01, /* Usage (Pointer) */
		0xA1,
		0x00, /* Collection (Physical) */
		0x95,
		0x03, /* Report Count (3) */
		0x75,
		0x01, /* Report Size (1) */
		0x05,
		0x09, /* Usage Page (Buttons) */
		0x19,
		0x01, /* Usage Minimum (01) */
		0x29,
		0x05, /* Usage Maximum (05) */
		0x15,
		0x00, /* Logical Minimum (0) */
		0x25,
		0x01, /* Logical Maximum (1) */
		0x81,
		0x02, /* Input (Data, Variable, Absolute) */
		0x95,
		0x01, /* Report Count (1) */
		0x75,
		0x05, /* Report Size (5) */
		0x81,
		0x03, /* Input (Constant) for padding */
		0x05,
		0x01, /* Usage Page (Generic Desktop) */
		0x09,
		0x30, /* Usage (X) */
		0x09,
		0x31, /* Usage (Y) */
		0x09,
		0x38, /* Usage (Wheel) */
		0x15,
		0x81, /* Logical Minimum (-127) */
		0x25,
		0x7F, /* Logical Maximum (127) */
		0x75,
		0x08, /* Report Size (8) */
		0x95,
		0x03, /* Report Count (3) */
		0x81,
		0x06, /* Input (Data, Variable, Relative) */
		0xC0, /* End Collection (Physical) */
		0xC0, /* End Collection (Application) */
	};

	hids_init_obj.rep_map.data = report_map;
	hids_init_obj.rep_map.size = sizeof(report_map);

	hids_init_obj.info.bcd_hid = BASE_USB_HID_SPEC_VERSION;
	hids_init_obj.info.b_country_code = 0x00;
	hids_init_obj.info.flags = (BT_HIDS_REMOTE_WAKE |
								BT_HIDS_NORMALLY_CONNECTABLE);

	hids_inp_rep =
		&hids_init_obj.inp_rep_group_init.reports[INPUT_REP_TEXT_IDX];
	hids_inp_rep->size = INPUT_REPORT_TEXT_MAX_LEN;
	hids_inp_rep->id = INPUT_REP_KEYS_REF_ID;
	hids_init_obj.inp_rep_group_init.cnt++;

	hids_inp_rep++;
	hids_inp_rep->size = INPUT_REP_BUTTON_LEN;
	hids_inp_rep->id = INPUT_REP_REF_BUTTON_ID;
	// hids_inp_rep->rep_mask = mouse_movement_mask;
	hids_init_obj.inp_rep_group_init.cnt++;

	hids_outp_rep =
		&hids_init_obj.outp_rep_group_init.reports[OUTPUT_REP_KEYS_IDX];
	hids_outp_rep->size = OUTPUT_REPORT_MAX_LEN;
	hids_outp_rep->id = OUTPUT_REP_KEYS_REF_ID;
	hids_outp_rep->handler = hids_outp_rep_handler;
	hids_init_obj.outp_rep_group_init.cnt++;

	hids_init_obj.is_kb = true;
	hids_init_obj.is_mouse = true;
	hids_init_obj.boot_kb_outp_rep_handler = hids_boot_kb_outp_rep_handler;
	hids_init_obj.pm_evt_handler = hids_pm_evt_handler;

	err = bt_hids_init(&hids_obj, &hids_init_obj);
	__ASSERT(err == 0, "HIDS initialization failed\n");
}

以上代码完成对HID设备的初始化以及回调函数的注册。

功能实现

前面完成了对板上外设的初始化、蓝牙连接以及USB HID描述符的定义,完成实验中要求的功能,只需要在指定的回调函数中实现响应的程序即可。

题目中要求按键1作为鼠标点击,按键2作为键盘输入按下时输入“eetree”字符。按键回调函数的执行逻辑如下图所示。

FldNXR4HCLDOAR19F5t6krwwviGM

由于鼠标和按键是不同的设备,在通过蓝牙向电脑端发送report数据时,根据描述符中定义的数据帧结构,填入相应的数据,即可完成功能,相关的代码如下:

static void mouse_click_send(struct mouse_status status)
{
	for (size_t i = 0; i < CONFIG_BT_HIDS_MAX_CLIENT_COUNT; i++)
	{

		if (!conn_mode[i].conn)
		{
			continue;
		}

		if (conn_mode[i].in_boot_mode)
		{
		}
		else
		{
			uint8_t buffer[INPUT_REP_BUTTON_LEN];

			memset(buffer, 0, sizeof(buffer));

			buffer[0] = status.left_pressed;

			printk("%d %d %d %d\r\n", buffer[0], buffer[1], buffer[2], buffer[3]);

			bt_hids_inp_rep_send(&hids_obj, conn_mode[i].conn,
								 INPUT_REP_BUTTON_IDX,
								 buffer, sizeof(buffer), NULL);
		}
	}
}

static void mouse_handler(struct k_work *work)
{
	struct mouse_status status;

	while (!k_msgq_get(&hids_queue, &status, K_NO_WAIT))
	{
		mouse_click_send(status);
	}
}

/** @brief Function process keyboard state and sends it
 *
 *  @param pstate     The state to be sent
 *  @param boot_mode  Information if boot mode protocol is selected.
 *  @param conn       Connection handler
 *
 *  @return 0 on success or negative error code.
 */
static int key_report_con_send(const struct keyboard_state *state,
							   bool boot_mode,
							   struct bt_conn *conn)
{
	int err = 0;
	uint8_t data[INPUT_REPORT_TEXT_MAX_LEN];
	uint8_t *key_data;
	const uint8_t *key_state;
	size_t n;

	data[0] = state->ctrl_keys_state;
	data[1] = 0;
	key_data = &data[2];
	key_state = state->keys_state;

	for (n = 0; n < KEY_PRESS_MAX; ++n)
	{
		*key_data++ = *key_state++;
	}
	if (boot_mode)
	{
		err = bt_hids_boot_kb_inp_rep_send(&hids_obj, conn, data,
										   sizeof(data), NULL);
	}
	else
	{
		err = bt_hids_inp_rep_send(&hids_obj, conn,
								   INPUT_REP_TEXT_IDX, data,
								   sizeof(data), NULL);
	}
	return err;
}

/** @brief Function process and send keyboard state to all active connections
 *
 * Function process global keyboard state and send it to all connected
 * clients.
 *
 * @return 0 on success or negative error code.
 */
static int key_report_send(void)
{
	for (size_t i = 0; i < CONFIG_BT_HIDS_MAX_CLIENT_COUNT; i++)
	{
		if (conn_mode[i].conn)
		{
			int err;

			err = key_report_con_send(&hid_keyboard_state,
									  conn_mode[i].in_boot_mode,
									  conn_mode[i].conn);
			if (err)
			{
				printk("Key report send error: %d\n", err);
				return err;
			}
		}
	}
	return 0;
}

/** @brief Change key code to ctrl code mask
 *
 *  Function changes the key code to the mask in the control code
 *  field inside the raport.
 *  Returns 0 if key code is not a control key.
 *
 *  @param key Key code
 *
 *  @return Mask of the control key or 0.
 */
static uint8_t button_ctrl_code(uint8_t key)
{
	if (KEY_CTRL_CODE_MIN <= key && key <= KEY_CTRL_CODE_MAX)
	{
		return (uint8_t)(1U << (key - KEY_CTRL_CODE_MIN));
	}
	return 0;
}

static int hid_kbd_state_key_set(uint8_t key)
{
	uint8_t ctrl_mask = button_ctrl_code(key);

	if (ctrl_mask)
	{
		hid_keyboard_state.ctrl_keys_state |= ctrl_mask;
		return 0;
	}
	for (size_t i = 0; i < KEY_PRESS_MAX; ++i)
	{
		if (hid_keyboard_state.keys_state[i] == 0)
		{
			hid_keyboard_state.keys_state[i] = key;
			return 0;
		}
	}
	/* All slots busy */
	return -EBUSY;
}

static int hid_kbd_state_key_clear(uint8_t key)
{
	uint8_t ctrl_mask = button_ctrl_code(key);

	if (ctrl_mask)
	{
		hid_keyboard_state.ctrl_keys_state &= ~ctrl_mask;
		return 0;
	}
	for (size_t i = 0; i < KEY_PRESS_MAX; ++i)
	{
		if (hid_keyboard_state.keys_state[i] == key)
		{
			hid_keyboard_state.keys_state[i] = 0;
			return 0;
		}
	}
	/* Key not found */
	return -EINVAL;
}

/** @brief Press a button and send report
 *
 *  @note Functions to manipulate hid state are not reentrant
 *  @param keys
 *  @param cnt
 *
 *  @return 0 on success or negative error code.
 */
static int hid_buttons_press(const uint8_t *keys, size_t cnt)
{
	while (cnt--)
	{
		int err;

		err = hid_kbd_state_key_set(*keys++);
		if (err)
		{
			printk("Cannot set selected key.\n");
			return err;
		}
	}

	return key_report_send();
}

/** @brief Release the button and send report
 *
 *  @note Functions to manipulate hid state are not reentrant
 *  @param keys
 *  @param cnt
 *
 *  @return 0 on success or negative error code.
 */
static int hid_buttons_release(const uint8_t *keys, size_t cnt)
{
	while (cnt--)
	{
		int err;

		err = hid_kbd_state_key_clear(*keys++);
		if (err)
		{
			printk("Cannot clear selected key.\n");
			return err;
		}
	}

	return key_report_send();
}

static void button_text_changed(bool down)
{
	static const uint8_t *chr = hello_world_str;

	if (down)
	{
		hid_buttons_press(chr, 1);
	}
	else
	{
		hid_buttons_release(chr, 1);
		if (++chr == (hello_world_str + sizeof(hello_world_str)))
		{
			chr = hello_world_str;
		}
	}
}

static void button_changed(uint32_t button_state, uint32_t has_changed)
{
	bool data_to_send = false;
	struct mouse_pos pos;
	struct mouse_status status = {0};
	static bool pairing_button_pressed;

	uint32_t buttons = button_state & has_changed;

	if (k_msgq_num_used_get(&mitm_queue))
	{
		if (buttons & KEY_PAIRING_ACCEPT)
		{
			pairing_button_pressed = true;
			num_comp_reply(true);

			return;
		}

		if (buttons & KEY_PAIRING_REJECT)
		{
			pairing_button_pressed = true;
			num_comp_reply(false);

			return;
		}
	}

	/* Do not take any action if the pairing button is released. */
	if (pairing_button_pressed &&
		(has_changed & (KEY_PAIRING_ACCEPT | KEY_PAIRING_REJECT)))
	{
		pairing_button_pressed = false;

		return;
	}

	// 处理按键1点击事件
	if (has_changed & BUTTON_CLICK_MASK)
	{
		status.left_pressed = (button_state & BUTTON_CLICK_MASK);
		data_to_send = true;
	}
	// 处理按键2发送“eetree”字符串
	if (has_changed & EETREE_TEXT_MASK)
	{
		button_text_changed((button_state & EETREE_TEXT_MASK) != 0);
	}

	if (data_to_send)
	{
		int err;

		err = k_msgq_put(&hids_queue, &status, K_NO_WAIT);
		if (err)
		{
			printk("No space in the queue for button pressed\n");
			return;
		}
		if (k_msgq_num_used_get(&hids_queue) == 1)
		{
			k_work_submit(&hids_work);
		}
	}
}

电脑开启大写锁定时,板卡的LED亮起。该功能的为对电脑端发送过来的数据进行检测,判断大写锁定按键是否按下,并对LED进行操作。

FsrIrki4uQWD7logpSfbXRFOwiSZ

处理流程定义在HID的输出设备的回调函数中定义,具体代码如下。

static void caps_lock_handler(const struct bt_hids_rep *rep)
{
	uint8_t report_val = ((*rep->data) & OUTPUT_REPORT_BIT_MASK_CAPS_LOCK) ? 1 : 0;
	dk_set_led(LED_CAPS_LOCK, report_val);
}

static void hids_outp_rep_handler(struct bt_hids_rep *rep,
								  struct bt_conn *conn,
								  bool write)
{
	char addr[BT_ADDR_LE_STR_LEN];

	if (!write)
	{
		printk("Output report read\n");
		return;
	};

	bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
	printk("Output report has been received %s\n", addr);
	caps_lock_handler(rep);
}

总结

Nordic官方的资料从基础的工程入门到复杂的应用,都给出了很多详细的例程,官方也配套了很多外围的应用供开发者快速基于其demo搭建验证示例,而且其成熟的代码可以作为开发的起点,减轻开发工作的同时,优秀的代码也帮助我们提升自身的编程能力。

附件下载
EETree_composite_hid_device.7z
工程源代码
团队介绍
嵌入式工程师
团队成员
EPTmachine
评论
0 / 100
查看更多
目录
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号