一.基本理论
1.属性协议与通用属性规范
我希望大家把教程中提供的应用程序当作一个跳板,将来开发程序的时候可以对其进行扩展和完善。我会尽可能少地提到理论,但是这里要提到的属性协议(ATT)和通用属性规范(GATT)是BLE中非常基础而且重要的知识点,所以这里要详细介绍一下。
1.1属性协议Attribute Protocol (ATT)
从下图可以看到,BLE协议栈分为多个层。应用层位于GATT之上,而GATT在ATT层上。属性协议是根据客户端<->服务器关系建立起来的。服务器上保存诸如传感器数据,位置数据,电灯开关状态之类的信息。这些信息以一种数据表的形式保存,这个表就称为属性表。属性协议定义了如何组织这个表以及如何存取表中的数据。表中的每条属性都是一个具体的数值或者具有特定意义的信息。例如客户端想要获取传感器数据,它可以访问属性表的第11行,想要获取灯的开关状态就可以访问第16行。
以下内容摘自蓝牙核心规范V4.2:
Vol 3:, Part F, Ch. 2, “Protocol Overview”:
属性协议定义了两种角色:服务器和客户端。客户端可以通过属性协议访问服务器上的属性表数据。属性表里的每条属性除了属性值外还有其它3个部分:(1)属性类型,通过UUID定义;(2)属性句柄;(3)上层协议定义的一系列权限控制位,用以控制对相应属性的操作,但是不能通过属性协议访问。
属性类型声明了本条属性代表什么。为了便于高层规范使用,蓝牙技术联盟定义了多种属性类型。开发者也可以自主定义属性类型。
现在我们来看一个具体的应用示例。下表是一个心率Profile,每一行是一条属性,每条属性都有属性句柄,属性类型,权限控制,属性值。
表1
属性句柄
属性句柄用来唯一标识服务器上的每条属性,同时客户端据此发送属性读写请求。为了方便理解,我们可以把属性句柄看作属性在属性表中的行号。但是属性句柄并不是连续增加的,允许跳过一些数值,但是整个大方向必需是增加的。属性句柄用4个十六进制数表示,随后我们会看到协议栈SoftDevice 利用这些句柄来操作属性。从开发者的角度来看,利用这种方法在函数间传递数据是非常高效的。应用程序借此可以很容易地追踪特定属性并获取需要的信息。属性句柄的数量根据应用需要的属性个数而变化。
属性类型(UUID)
UUID是一组16位或者128位的数字,用以标识每个属性的类型。在上表中有5种不同类型的属性:1.服务声明(0x2800)2.特性声明(0x2803)3.心率测量特性值(0x2A37)4.体传感器位置特性值(0x2A38)5.描述符声明(0x2902)。除此之外,还有其它种类的属性类型,在本教程中,我们将自定义属性类型。
属性权限
属性权限定义了与属性交互的规则。它定义了该条属性是否可读可写以及进行读写操作时需要进行哪种授权。这里需要强调的是属性权限只能用来控制对属性值的操作,它不用于属性句柄,属性类型以及属性权限本身。客户端可以通过遍历服务器上的属性表,就可以获悉服务器可以提供的服务,不需要读写属性值。
属性值
属性值可以是任何东西。它可以是每分钟心率数值,可以是灯的开关状态,或者是像“hello world”之类的字符串。有时候它还可以包含某些信息,通过这些信息可以找到其它属性及其性质。例如在上表中的服务声明属性(属性句柄0x000C)中,属性值是0x180D,这是蓝牙技术联盟定义的服务UUID,用以标识这是哪种服务。特性声明属性(属性句柄0x000D)的属性值包含了下一条属性即特性值声明属性的信息,包括属性句柄,属性类型,属性权限。最后,心率测量特性值属性(属性句柄0x000E)的属性值中包含了每分钟的心率数值。
1.2通用属性规范(GATT)
通用属性规范的概念是指将属性表中的属性以一种特定的逻辑顺序组合在一起。上表中的心率profile就是这种分组的一个实例。
1.2.1服务声明属性
在每一组属性的最前面是一条服务声明属性,它的属性类型总是0x2800,即标准服务声明UUID。属性句柄取决于属性表中有多少属性。属性权限是只读,不需要任何认证或授权。属性值是一个标识其为何种服务的UUID,在上表中,这个UUID为0x180D,它是蓝牙技术联盟定义的心率服务UUID。本文后面部分我们会自己制作属性表,创建自己的服务声明UUID。
1.2.2特性声明
紧随服务声明属性之后的一般是特性声明属性(也可能有例外情况,本文对此不做详解)。特性声明与服务声明类似。特性声明属性的属性类型是0x2803,即标准特性声明UUID。特性声明属性的属性权限总是只读类型的,不需要任何认证或授权。其属性值包含了下一条特性值声明属性的信息,包括属性句柄,属性类型以及属性性质,其中属性句柄描述了下一条特性值声明属性在属性表中的位置,属性类型UUID描述了下一条特性值声明属性中包含那种信息或数据,例如温度值,开关状态等等,属性性质描述了如何与特性值进行交互。下表展示了属性性质控制位信息,随后我们将详细介绍这个表。
表2
说到这里你可能会疑问了,为什么每条属性已经有属性权限来控制属性值的读写了,还需要其他的属性性质来控制特性值的读写呢?它们不应该是一样的吗?这是一个值得一提的问题。事实上,控制特性值的属性性质只用在GATT和应用层,仅仅为客户端提供一些线索,客户端可以据此知道从特性值声明属性中能够获取哪些内容。控制属性的属性权限(ATT层)凌驾于特性值性质(GATT层)之上,最终决定对属性值的访问。你可能会问,为什么需要属性权限和性质两个部分呢?答案很简单,因为蓝牙核心规范是这样规定的。可能这个答案很令人困惑和失望,但是蓝牙核心规范决定我们如何设置这些特性,所以这里要提一下。
1.2.3特性值声明
特性声明之后是特性值声明。这条属性包含最终的数据值。这个数据值可能是温度值,开关状态等等。特性值声明的属性类型与特性声明的属性值中定义的类型相同。特性值声明的属性权限由上层应用规定。
1.2.4描述符声明
特性值声明之后的下一条属性可能是:
1)新的特性声明(一种服务中可以包含多种特性)
2)新的服务声明(一个属性表中可以包含多种服务)
3)描述符声明
表1中,特性值声明之后是描述符声明。这条属性包含关于本条特性的其它信息。描述符有许多种,本文我们只涉及客户端特性配置描述符(CCCD),之后我们会对其做详细介绍。
二.MCP中的属性表(译者注:MCP软件已更新为nRF Connect软件)
我们已经学了许多相关理论了,现在让我们看看在实际中这些理论是如何体现的。我们以SDK中提供的心率服务为例。下载程序,连接nRF51 DK板,在MCP上运行服务发现程序,我们就可以看到整个属性表。每一行是一条属性,并按服务进行分组。如下图1所示。
在图1中,我标记了服务声明属性。你可以看到在红框中,这条属性的UUID是0x2800,属性句柄是0x000C,属性值是0x180D(低位在前,LSB)。这与我们在表1中看到的一致。UUID显示了该条属性是一条服务声明属性,属性值显示了它是心率服务。
在图2中我标记了第一条特性声明属性。其UUID是0x2803,显示了它确实是一条特性声明属性。这里的属性值就比较奇怪了,下面我们解释一下它代表的含义。属性值里的数据是以LSB形式组织的,为了方便阅读,我们将其改为MSB形式:2A37-000E-10。你可能已经注意到,2A37是心率测量特性的UUID,000E是特性值声明属性的属性句柄,表征其在属性表中的位置。0x10定义了属性性质,与表2进行对比可以发现它代表了“通知”。上面提到的这些都与我们在表1中的描述一致。
在图3中,你可以看到特性值声明的UUID确实是0x2A37,而且属性句柄就是0x000E,正如图2中特征声明属性所指定的那样。
最后,在图4中,你可以看到CCCD属性的值。你可以看到描述符UUID(0x2902),而属性值是0x0000,这意味着通知现在被关闭了。随后我们会再详细介绍相关内容。
三.操作实例
现在是时候实际操作一下了。我认为作为一个例子,我们应该首先创建一个可读可写的特性,然后我们就可以手动读写数据了。为了防止你们不知道芯片内部有温度传感器,我们可以将nRF5x芯片CPU温度值放入特性中。它可能不是很准确,但是这是一种非常简单的方法,可以帮助我们熟悉如何使用动态传感器值。最后,我们会启用通知来更新温度值。
步骤:1.添加服务。本系列教程的上一篇文章介绍了如何创建服务;
2.声明与配置特性;
3.添加CCCD,使得特性在温度改变时,或者周期性地发送温度值。
表3是我们要包含的属性表。
第一步:添加服务
上面我们已经提到,这一步是在上一篇教程中完成的,现在我们快速了解一下最终结果在MCP中是什么样子的。如下图所示:
标亮的一行是服务声明,声明我们提供何种服务。你可以看到属性类型是0x2800,属性句柄是0x000C,属性值是128位自定义UUID。
第二步:添加特性
调用SoftDevice函数sd_ble_gatts_characteristic_add()。该函数会向属性表中添加特性声明属性和特性值声明属性。但是为了达到这个目的,我们必须先做一些事情。sd_ble_gatts_characteristic_add()接受四个参数:
@param[in] uint16_t service_handle.
@param[in] ble_gatts_char_md_t const * p_char_md
@param[in] ble_gatts_attr_t const * p_attr_char_value
@param[out] ble_gatts_char_handles_t * p_handles
你可以看到,该函数有三个输入参数和一个输出参数。我们需要填入三个输入参数来定义特性属性。这些参数定义了属性性质、读/写权限、描述符、特征值等。实际上,在定义特性时,可以使用70多种性质。这听起来令人生畏,但不要放弃,更不要扔掉你的DK板。大多数参数都是可选的,如果使memset(¶meter, 0, sizeof(parameter)),将它们初始化为0,则几乎所有的参数都可以正常工作。
我们会先填充我们需要的基本参数。事实上,我们所要做的只是选择在哪里存储特性属性,以及使用我们自定义的UUID定义一个特性值类型。在函数our_char_add()中,我已经声明了所有必需的变量,并将它们初始化为0。我声明它们的顺序可能有些混乱,但是由于参数嵌套到结构中,这些结构可能还会嵌套到其他结构中,所以我不得不这样做。同时为了与SDK中的命名惯例一致,我选择了一些看起来有点不直观的名字。
三个最重要的变量是:
1. ble_gatts_attr_md_t attr_md,即属性Metadata:这个结构体包含特性值属性需要的属性权限和授权等级,以及特性值是否为可变长度,还有属性值的存储位置。
2. ble_gatts_char_md_t char_md,即特性Metadata:这个结构体包含特性的性质,如读写控制,以及与CCCD或者其它描述符相关的信息。
3. ble_gatts_attr_t attr_char_value, 即特性值属性:这个结构体包含实际的特性值(如温度值)。它还包含属性值的最大长度(例如4字节长度)和属性UUID。
接下来我们开始填充这些参数。为了便于理解,我会分步进行,并做详细解释。
A. 使用定制UUID定义特性值类型
特性和服务一样都需要添加UUID,添加方法相同。在our_char_add()函数中添加下面几行:
uint32_t err_code;
ble_uuid_t char_uuid;
ble_uuid128_t base_uuid = BLE_UUID_OUR_BASE_UUID;
char_uuid.uuid = BLE_UUID_OUR_CHARACTERISTC_UUID;
err_code = sd_ble_uuid_vs_add(&base_uuid, &char_uuid.type);
APP_ERROR_CHECK(err_code);
特性和服务使用相同的基础UUID,但是使用不同的16位UUID。我们在our_service.h文件中将这条特性的UUID定义为0xBEEF。在前面的教程中我们已经提到如何将基础UUID添加到特定厂商表中,这样对相同基础UUID的调用就会参照相同的特定厂商表,这样就不需要保存全部的128位UUID,只要使用16位UUID再加上特定厂商表中的基础UUID即可,从而节省储存空间,
B.配置属性Metadata
我们只需要下面3行代码就可以描述特性属性。这几行代码决定了在哪保存属性。我们打算在SoftDevice(也称为协议栈)中保存属性,所以我们使用了BLE_GATTS_VLOC_STACK。也可以使用BLE_GATTS_VLOC_USER来让用户定义存储位置。
ble_gatts_attr_md_t attr_md;
memset(&attr_md, 0, sizeof(attr_md));
attr_md.vloc = BLE_GATTS_VLOC_STACK;
在属性metadata结构体ble_gatts_attr_md_t 中,你可以定义属性权限,例如你可以设置中间人防护(MITM)或者访问属性需要密码。
C.配置特性值属性
将前面创建的UUID以及属性metadata带入对于结构。
ble_gatts_attr_t attr_char_value;
memset(&attr_char_value, 0, sizeof(attr_char_value));
attr_char_value.p_uuid = &char_uuid;
attr_char_value.p_attr_md = &attr_md;
D.向结构体中加入特性的属性句柄
我们需要向服务结构体中加入一个变量,用以保存特性的属性句柄。所以在our_service.h中进行如下操作。
typedef struct
{
uint16_t conn_handle;
uint16_t service_handle;
// OUR_JOB: Step 2.D, Add handles for our characteristic
ble_gatts_char_handles_t char_handles;
}ble_os_t;
我们可以看到,ble_os_t结构体中已经有了一个用于服务声明的属性句柄。其中的连接句柄conn_handle用于保存当前连接,它不对属性表做任何操作。继续看ble_gatts_char_handles_t的定义,可以发现这个结构体中包含特性值属性、用户描述符、CCCD以及服务器特性配置描述符(SCCD)共四种变量的16位属性句柄,其中SCCD相关内容本教程不涉及。
E.向服务中加入新特性
现在我们要向属性表中加入新特性,如下所示:
err_code =sd_ble_gatts_characteristic_add(p_our_service->service_handle,
&char_md,
&attr_char_value,
&p_our_service->char_handles);
APP_ERROR_CHECK(err_code);
在上面的代码中,我们向SoftDevice中填入相关的信息,包括新加的特性属于哪个服务(服务句柄),特性Metadata和特性值属性。然后协议栈处理这些参数并初始化该特性。最后将特性的属性句柄保存到p_our_serice结构中。
现在将例程编译并下载到DK板中,结果如下图所示:
我们可以看到,服务中多了一个新特性,但是它现在还没什么用处,因为它没有值,也不能对其进行读写操作。你可以尝试读写一下,MCP会发送读写请求,但是由于我们还没有定义任何读写权限,读/写属性权限默认初始化为0,所以DK板会拒绝任何读写请求。MCP工作log如下图所示:
F.向特性值中加入读/写性质
首先在our_char_add()函数中加入下面几行:
ble_gatts_char_md_t char_md;
memset(&char_md, 0, sizeof(char_md));
char_md.char_props.read = 1;
char_md.char_props.write = 1;
这样就会在特性的metadata中加入读/写性质。编译并下载程序后可以看到操作已经生效:
现在再尝试一下读写操作。结果我们会发现还是不能正常读写。为什么会出现这种情况呢?如果你是从本文开头阅读到这里的,你就会发现,我们仅仅设置了特性声明属性的属性值中的性质位,它仅仅是一种指导说明,真正的属性权限还没有设置。因此协议栈不知道该如何操作,所以拒绝了对该特性的读写访问。
G.设置特性的读/写权限
现在我们设置特性的读写权限,为了方便,我们将其设置为没有加密,不需要密码。我们在 our_char_add() 函数中加入下面两行:
BLE_GAP_CONN_SEC_MODE_SET_OPEN(&attr_md.read_perm);
BLE_GAP_CONN_SEC_MODE_SET_OPEN(&attr_md.write_perm);
程序运行结果如下:
现在你就可以进行读写操作了。结果如下图所示。我们读取了0字节,因为还没有赋值以及设置值长度。
尝试写入0x12,结果如下:
H.设置特性的值长度
如何解决上面的问题呢?添加如下代码即可:
attr_char_value.max_len = 4;
attr_char_value.init_len = 4;
uint8_t value[4] = {0x12,0x34,0x56,0x78};
attr_char_value.p_value = value;
设置初始长度以及最大程度为4,特性值就会有4字节长度。同时设置初始值。现在就可以进行读写操作了,写入的值会保存在DK板上,下次连接时还会保留。
下面的操作可以自行尝试一下:
1. 向特性中写入1字节,如‘12’
2. 向特性中写入4字节,如‘12-34-56-78’
3. 向特性中写入5个或者更多字节,如‘12-34-56-78-90’
为什么不允许写入超过4字节?如何修改?
使用下面几个宏定义修改属性权限,然后进行读写操作,看看会有什么结果。
1.BLE_GAP_CONN_SEC_MODE_SET_NO_ACCESS(),2.BLE_GAP_CONN_SEC_MODE_SET_OPEN()
3.BLE_GAP_CONN_SEC_MODE_SET_ENC_NO_MITM()
这里提示一下,对于第3种情况,当点击MCP上的绑定操作后,会自动加密蓝牙连接,但是不使用中间人防护(MITM)。
第三步:客户端特性配置描述符
我们可以添加温度数据,然后让DK板周期性地向客户端发送温度数据。为此我们需要:
1. 添加数据发送函数;
2. 管理蓝牙连接和服务;
3. 设置定时器周期性更新数据。
蓝牙核心规范定义了两种发送数据的方法:
Vol 3: Part G, Ch. 4.10 & 4.11:
指示-服务器将特性值指示给客户端,客户端收到指示后发送应答。
通知-服务器将特性值通知给客户端,不需要应答。
本着方便的原则我们这里使用通知,不需要应答。为了添加通知功能,我们需要向属性表中加入一个描述符,即客户端特性配置描述符(CCCD),蓝牙核心规范对其定义如下:
Vol 3: Part G, Ch 3.3.3.3:
客户端特性配置描述符是一种可选择的描述符,它定义了某个特性可以被特定客户端如何配置…
上面比较费解的两行是说CCCD是一种可写的描述符,客户端如MCP或者手机可以通过写入CCCD来启用或停止指示或通知。
A.配置CCCD metadata
将CCCD添加到特性中:
ble_gatts_attr_md_t cccd_md;
memset(&cccd_md, 0, sizeof(cccd_md));
BLE_GAP_CONN_SEC_MODE_SET_OPEN(&cccd_md.read_perm);
BLE_GAP_CONN_SEC_MODE_SET_OPEN(&cccd_md.write_perm);
cccd_md.vloc = BLE_GATTS_VLOC_STACK;
char_md.p_cccd_md = &cccd_md;
char_md.char_props.notify = 1;
1. 定义CCCD metadata结构体变量来保存配置;
2. 设置CCCD属性权限;
3. 设置描述符保存位置为协议栈;
4. 将CCCD metadata结构体保存到特性metadata结构体中;
5. 启用通知功能。
编译并下载程序后可得:
我们可以看到“通知notify”已经加入到特性声明的性质中。CCCD的UUID是0x2902,属性值是0x0000,意思是通知和指示均关闭。如果你想启用通知,可以写入相应的值即可,但是我们没有更新温度数据,所以并不会发送通知。
B.连接管理1:设置服务连接句柄为默认值
在简单的例子中这一步可以不必做,但是当我们开发高级一点的应用时,这就很关键了。另外由于每个BLE例程中都涉及到了这一点,所以这里还是要提一下。
服务结构体ble_os_t中有一个变量conn_handle,它用来保存当前连接句柄,该句柄由BLE协议栈提供。系统启动时需要初始化conn_handle,由于系统启动时还没有连接,所以在SDK中我们将其初始化为BLE_CONN_HANDLE_INVALID(值为0xFFFF)。我们只需要到our_service.c文件中找到our_service_init()函数,加入下面一行即可:
p_our_service->conn_handle = BLE_CONN_HANDLE_INVALID;
C.连接管理2:对连接和断开事件做响应
在main.c文件中有一个函数ble_evt_dispatch(),当BLE协议栈中有事件发生时,会调用此函数。为了让我们添加的服务与最新的连接事件同步,可以在ble_evt_dispatch()函数中调用如下函数:
ble_our_service_on_ble_evt(&m_our_service, p_ble_evt);
D.连接管理3:处理与我们新加入的服务相关的BLE事件
在上面的ble_our_service_on_ble_evt()函数中,加入如下代码。在连接成功后保存连接句柄,断开连接时设置连接句柄为初始值。
switch (p_ble_evt->header.evt_id)
{
case BLE_GAP_EVT_CONNECTED:
p_our_service->conn_handle = p_ble_evt->evt.gap_evt.conn_handle;
break;
case BLE_GAP_EVT_DISCONNECTED:
p_our_service->conn_handle = BLE_CONN_HANDLE_INVALID;
break;
default:
// No implementation needed.
break;
}
E.更新特性值
在our_termperature_characteristic_update()函数中加入如下代码就可以实现通知功能。在程序中,首先检查是否存在有效连接,如果在没有有效连接的情况下发送通知,协议栈会返回错误。
if (p_our_service->conn_handle != BLE_CONN_HANDLE_INVALID)
{
uint16_t len = 4;
ble_gatts_hvx_params_t hvx_params;
memset(&hvx_params, 0, sizeof(hvx_params));
hvx_params.handle = p_our_service->char_handles.value_handle;
hvx_params.type = BLE_GATT_HVX_NOTIFICATION;
hvx_params.offset = 0;
hvx_params.p_len = &len;
hvx_params.p_data = (uint8_t*)temperature_value;
sd_ble_gatts_hvx(p_our_service->conn_handle, &hvx_params);
}
在这段程序中你可能会奇怪hvx代表什么,它不怎么直观,其实hvx代表Handle Value X,X表示通知或指示。这里我们建立了一个ble_gatts_hvx_params_t类型的变量hvx_params来保存通知相关数据,然后将其提供给sd_ble_gatts_hvx()函数,这些数据包括:
1. handle:协议栈需要知道我们操作的是哪个特性值,所以我们需要向其提供句柄,即p_our_service->char_handles.value_handle。
2. type:协议栈需要知道我们想要使用的是通知还是指示,这里我们使用通知BLE_GATT_HVX_NOTIFICATION。
3. offset:需要传输的特性值可能有多个字节,如果你只想传输其中的几个字节,可以设置偏移量offset。这里我们需要传输全部四个字节,所以设置offset为0.
4. p_len:需要告知协议栈的传输字节数。如果我们只想传输四个字节,那就不需要每次传输20字节了。比如有四个字节: 0x01, 0x02, 0x03, 0x04, 0x05,我们想传输第三和第四字节,则设置offset为2,len为2即可。
5. p_data:实际数据保存位置。
最后我们将这个结构体传入sd_ble_gatts_hvx()函数中,并输入相关连接句柄。因为在有些应用中可能同时存在多个连接,所以我们要向协议栈函数提供相应的连接句柄。
F.将温度数据更新到特性中
现在我们需要测量温度并更新温度数据。在main.c文件中的timer_timeout_handler()函数中加入如下代码:
int32_t temperature = 0;
sd_temp_get(&temperature);
our_termperature_characteristic_update(&m_our_service, &temperature);
nrf_gpio_pin_toggle(LED_4);
1. temperature:临时变量保存温度数据。
2. sd_temp_get():协议栈函数,获取温度值。
3. our_termperature_characteristic_update():调用温度更新函数并传入数据和服务变量。
4. nrf_gpio_pin_toggle():点亮DK板LED进行提醒。
G.设置定时器ID和定时器间隔
我们还需要设置测量触发条件。例如按钮按下或者从客户端接收到特定命令。在我们的例子中,我们使用定时器来周期性地测量温度。我们需要做的第一件事是设置定时器间隔,我们需要一个定时器ID来标识这个特定的计时器。因此在main.c中输入下面几行,定义定时器ID变量,并定义一个1000 ms的定时器间隔:
APP_TIMER_DEF(m_our_char_timer_id);
#define OUR_CHAR_TIMER_INTERVAL APP_TIMER_TICKS(1000, APP_TIMER_PRESCALER) // 1000 ms intervals
H.初始化定时器
app_timer_create(&m_our_char_timer_id, APP_TIMER_MODE_REPEATED, timer_timeout_handler);
APP_TIMER_MODE_REPEATED设置定时器触发方式为重复触发。timer_timeout_handler为定时器溢出处理函数。
I.启动定时器
在Nordic函数库中大多数情况下初始化不代表启动,所以我们还要启动定时器。第三个函数参数是一个通用指针,可以传入timer_timeout_handler,这里设为NULL即可。
app_timer_start(m_our_char_timer_id, OUR_CHAR_TIMER_INTERVAL, NULL);
编译下载程序后,如果一切正常的话,我们会发现LED灯闪烁,MCP Log如下:
将手指放在芯片上可以发现温度值变化。另外读者可以尝试1.修改 our_termperature_characteristic_update()函数,达到只有温度变化时才发送数据的效果;2.修改定时器,连接建立时启动定时器,断开连接后停止定时器。
四.总结
到这里我们的工作就基本完成了,我们建立了一个属性表并添加了一个基本的数据传输特性。它还有许多局限,比如只能单向通信并且没有进行安全加密。但是我希望我已经达到我的目的:交给你一些新的知识,为你提供一个跳板,你可以就此展开深入研究。