- 二进制数据专题
« 返回通信连接组件 · 二进制组件参考 · 二进制组件使用指导
二进制数据专题
单片机、传感器模块、工控设备之间传数据,大多不是发一句文本,而是发一串字节: 「帧头 A5、命令 01、两个字节的温度、一个字节的校验」。这类格式叫二进制协议。
App Inventor 里处理二进制只需要记住一件事:字节列表就是由 0~255 的数字组成的普通列表。 下面这些组件都用字节列表收发,二进制组件负责把数字、文本、校验和字节列表互相转换。
第一次使用,先在下面按设备接口选一个案例或工程,改必要的连接参数,再验证设备回复。无需先读完全部方法,也不必为了用蓝牙或串口先学习 TCP。所有代码块使用真实组件方法;英文标识用于生成积木,正文名称与中文界面对应。
先按 App 目标选案例
| 你要做的 App | 最少需要的能力 | 从这里开始 |
|---|---|---|
| 开关、继电器、灯控 | 拼命令 → 发送 → 收到设备确认后更新状态 | TCP 温湿度与灯控、经典蓝牙控制帧 |
| 实时温湿度、压力、重量、位置 | 接收完整帧 → 校验 → 按协议的类型/比例显示 | 温湿度完整闭环、BLE float 通知 |
| Arduino、USB 转串口模块调试 | 打开串口 → 原始字节读写 → 显示回复 | 串口完整回显 App;无需改成蓝牙或 Wi-Fi |
| 电表、工业仪表 | 按设备协议设置地址/命令/寄存器 → 校验与解析 | Modbus RTU 透传;标准 Modbus TCP 的 MBAP 头不能照抄 RTU 帧 |
| 中文串口屏、蓝牙打印机 | 设备命令 + 文本编码 + 字节发送 | 文本互转、拼接命令;先确认型号和编码 |
| 多设备状态、云端控制 | 区分设备主题 → 请求/回复 → 连接与超时 | MQTT 两端完整案例 |
| 长卡号、设备编号、64 位标识 | 保留原始字节/十六进制文本 → 比较/保存/转发 | 大编号处理;无需转成可能失真的数字 |
先只掌握三件事:收发用同一种字节列表;整数/小数按设备文档转换;设备回复正确才算动作完成。最常用的发送组合只有“拼接字节 → 追加校验 → 发送字节”;最常用的接收组合是“得到完整帧 → 核对校验 → 取数显示”。设备没有校验字段时无需人为追加。
TCP、串口与跨多次 BLE 通知的业务帧,用已给出的缓存拆帧模板;用户按协议调整帧头、长度和字段位置。WebSocket/MQTT 已给完整消息时直接解析。重连清空旧缓冲,超时提示未确认,防止把上次会话的数据当成新设备回复。这些流程已放在完整案例中,初学者可以直接复用。
不要为了对应 Java/Python 的某个 API 再学格式字符。两端只要使用同一份设备协议,现有的拼接、整数、小数、文本和校验方法已经能表达这些字段;用什么语言写对端不影响 App 的接口。
示例工程与测试程序
这些生成工程已通过实际后台导入、原始积木载入及 Android APK 编译检查,详见验证记录。尚未验证浏览器人工导入、AI 伴侣和真机运行。下表提供复核与联调步骤;遇到差异时以实际结果为准。
保留本页原有组合用法,在下面补齐了完整闭环步骤。有局域网设备时可先做 TCP;USB/蓝牙设备直接看对应案例;不要把本页所有案例粘进同一个屏幕。
| 示例工程(后台导入与 APK 编译已通过) | 对端准备 | 运行步骤与预期 |
|---|---|---|
| USB 串口回显 SerialBinaryLab.aia | 驱动支持的 USB 转串口模块,原样回显 | 打开 → 允许权限 → 发送;收到 A5 01 05 DC 87 才确认通过;见硬件准备与完整案例 |
| TCP 温湿度与灯控 BinaryTcpLab.aia | binary_device.py,Python 3 | 运行 python binary_device.py --fixed;App 填电脑 IP → 连接 → 读取,显示 23.5 ℃、45 %;开灯/关灯后收到确认;另有离线拆帧按钮 |
| WebSocket 回显 BinaryWebSocketLab.aia | binary_transport.py,Python 3 | 运行 python binary_transport.py;App 填电脑 IP → 连接 → 发送,显示 A5 02 00 EB 92 和 23.5 |
| HTTP 回显 BinaryHttpLab.aia | 同一个 binary_transport.py |
App 填电脑 IP → 上传,回复还原为整数 1500、小数 23.5 |
| MQTT 控制端 A + MQTT 模拟设备 B | 可访问的 Broker;没有 Broker 时见本地准备 | 两台客户端分别运行 A、B,填同一 Broker 地址;先连接 B,再连接 A,等订阅稳定后 → 读取,A 收到 B 的 23.5 ℃回复 |
导入方法:下载 .aia 文件,在编辑器选择“项目 → 导入项目(.aia)”,导入后连接包含这些内置组件的最新版 AI 伴侣。工程已配置组件名称、默认属性和完整积木;地址输入框默认 192.168.1.10,运行时改为你的电脑 IP。无需导入扩展,也无需先手工添加组件。两端不在同一局域网时,这些本地地址不能直接访问。
手工复制方法:按各案例的组件表新建组件并重命名 → 在完整案例的代码块图上点“复制代码块” → 切到编辑器代码块视图,在空白工作区粘贴。组件名称必须逐字一致;不要把原始伪代码文本直接当成可粘贴的块 XML。下方“组合用法”属于独立片段,完整可运行项目见相应闭环章节。
找电脑 IP:Windows 在终端运行 ipconfig,选择连接 Wi-Fi/有线网卡的 IPv4;macOS 在系统设置的网络详情查看;Linux 可运行 hostname -I,选择与手机在同一网段的地址。输入框只填 IP 或主机名,例如 192.168.1.10,不填 http://、ws:// 或端口。
定位问题顺序:服务端窗口是否启动 → 手机是否能访问电脑 → App 是否显示已连接/HTTP 状态 → 对端是否打印收到的原始字节 → App 是否收到有效回复。电脑防火墙按案例允许 TCP 8888、8080 或 1883;不要根据“按钮已点击”判断通信已经完成。Python 命令不存在时,Windows 可用 py -3,macOS/Linux 可用 python3 替代。
字节列表
十六进制里一个字节写成两位,A5 就是 165,FF 就是 255。同一帧数据有三种写法,意思完全一样:
| 写法 | 例子 | 在哪里用 |
|---|---|---|
| 字节列表 | [165, 1, 5, 220] |
所有收发积木、列表积木 |
| 十六进制文本 | "A5 01 05 DC" |
协议文档、串口助手、调试显示 |
| 数字的含义 | 帧头 A5、命令 1、数值 1500 | 你的程序逻辑 |
因为是普通列表,现成的列表积木都能用:「求列表长度」就是字节数,「选择列表项」取第几个字节(从 1 数起), 「追加列表」把两段接起来。显示给人看时用「字节转十六进制」:
when 按钮1.Click() {
var 帧 = 二进制1.FromHex("A5 01 05 DC")
标签1.Text = join("共 ", length(帧), " 个字节,第 1 个是 ", 帧[1], ",十六进制 ", 二进制1.ToHex(帧))
}
标签显示「共 4 个字节,第 1 个是 165,十六进制 A5 01 05 DC」。
支持二进制的组件
| 组件 | 发送字节 | 接收字节 | 说明 |
|---|---|---|---|
| 二进制组件 | — | — | 拼帧、十六进制与文本互转、多字节数字、截取查找、校验。所有组合的中枢 |
| 蓝牙客户端(经典蓝牙,HC-05/HC-06) | 发送字节数组(列表) | 可接收字节数 + 接收无符号字节数组(个数) | 不会主动通知,用计时器定时查有没有数据 |
| 低功耗蓝牙(BLE,ESP32/nRF) | 写入字节(服务, 特征, 有符号, 列表) | 注册接收字节 订阅后在 收到字节时 收到 | 一次通知有边界,业务帧可能跨通知 |
| Socket客户端(TCP) | 发送字节数组(字节列表) | 收到字节时(字节列表) | TCP 是字节流,要自己拆帧,见拆帧 |
| WebSocket客户端 | 发送字节数组(字节列表) | 收到字节时(字节列表) | 一条消息就是一整包,不用拆帧 |
| MQTT客户端 | 发布二进制消息(主题, 字节列表) | 收到消息时的 Payload(字节列表) | 同一个事件里 Message 是按 UTF-8 转成的文本 |
| Web客户端 | 用字节转Base64放进网址或 JSON | 接口返回 Base64 时用 Base64转字节 | HTTP 接口传二进制的通用做法 |
| 串口通信器(USB 串口) | 写入字节(列表) | 读取字节(),用计时器轮询 | 与其他组件共用字节列表;先初始化并打开,一次读取不等于一帧,见串口完整案例 |
我要做……用什么
| 我要做 | 用这个 |
|---|---|
| 按协议拼一帧(帧头 + 命令 + 数据) | 拼接字节,一块积木拼完 |
| 把 1500、-20 这样的数字放进 2 个字节 | 整数转字节 |
| 把收到的两个字节还原成数字 | 字节转整数,可能为负的选「有符号」 |
| 单片机发来的 float | 字节转小数,字节数 4 |
| 协议要求 CRC 或累加校验 | 发送前追加校验,收到后核对校验 |
协议文档给的是 A5 01 FF 这样的十六进制,或用户在文本框里输入十六进制 |
十六进制转字节,转成字节列表再交给发送积木 |
| 显示收到了什么(调试) | 字节转十六进制 |
| 数据里夹着中文或英文命令 | 文本转字节 / 字节转文本,国内设备常用 GBK 编码 |
| 一个字节里每一位代表一个开关 | 取位 / 设置位 |
| 在一串数据里找帧头 | 查找字节,再用截取字节取出一帧 |
组合用法
下面先提供字段和收发组合片段;完整项目与对端程序见闭环主案例、WebSocket 回显、MQTT 双端和HTTP 回显。不同案例相互独立,合并时统一字节顺序与协议,并合并同名事件。
下面的案例里,协议格式都写在代码上方。片段默认连接已建立;蓝牙连接设置见蓝牙接入,其余连接步骤见对应闭环案例。换成你的设备时,照设备的协议文档改帧头、字节数和校验类型即可。
经典蓝牙:发送小车控制帧
协议:A5 + 左轮速度(1 字节,有符号,-100~100)+ 右轮速度 + 异或校验。两个滑动条的范围设为 -100 到 100:
procedure 发送速度(左, 右) {
var 帧 = 二进制1.Build([165, 左, 右])
if (蓝牙客户端1.IsConnected) {
蓝牙客户端1.SendBytes(二进制1.AppendChecksum(帧, "XOR8"))
标签1.Text = 二进制1.ToHex(二进制1.AppendChecksum(帧, "XOR8"))
} else {
标签1.Text = "请先连接蓝牙设备"
}
}
when 滑动条_左.PositionChanged(滑块位置) {
发送速度(round(滑动条_左.ThumbPosition), round(滑动条_右.ThumbPosition))
}
when 滑动条_右.PositionChanged(滑块位置) {
发送速度(round(滑动条_左.ThumbPosition), round(滑动条_右.ThumbPosition))
}
左轮 50、右轮 -50 时发出 A5 32 CE 59:-50 按补码是 CE。单片机端用 int8_t 读就是 -50。
经典蓝牙:定时接收定长帧
协议:设备每秒发 5 个字节:A5 + 温度(2 字节,有符号,乘了 10)+ 湿度(1 字节)+ 累加校验。
经典蓝牙不会主动通知,放一个计时器(间隔 200 毫秒)定时看有没有攒够一帧:
when 计时器1.Timer() {
if (蓝牙客户端1.IsConnected) {
if (蓝牙客户端1.BytesAvailableToReceive() >= 5) {
var 帧 = 蓝牙客户端1.ReceiveUnsignedBytes(5)
if (帧[1] == 165 and 二进制1.VerifyChecksum(帧, "SUM8")) {
标签1.Text = join("温度 ", 二进制1.BytesToNumber(帧, 2, 2, true) / 10, " ℃ 湿度 ", 帧[4], " %")
}
}
}
}
收到 A5 FF 38 2D 09 显示「温度 -20 ℃ 湿度 45 %」:FF 38 按有符号是 -200,除以 10 得 -20。
如果连上时正好赶上半帧,这种写法会一直对不齐,数据要求严格时改用下面拆帧的写法。
低功耗蓝牙:订阅 ESP32 的 float 数据
ESP32 常见写法是把 float 变量直接发出去,占 4 个字节、低字节在前。
设备每次通知 8 个字节:温度 float + 湿度 float。按钮向设备写 3 个字节的开灯命令:
global 服务 = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
global 通知特征 = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
global 写入特征 = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
when 低功耗蓝牙1.Connected() {
二进制1.HighByteFirst = false
低功耗蓝牙1.RegisterForBytes(服务, 通知特征, false)
}
when 低功耗蓝牙1.BytesReceived(服务UUID, 特征UUID, 字节值) {
if (upper(服务UUID) == upper(服务) and upper(特征UUID) == upper(通知特征) and length(字节值) == 8) {
标签1.Text = join("温度 ", round(二进制1.BytesToFloat(字节值, 1, 4) * 10) / 10, " ℃ 湿度 ", round(二进制1.BytesToFloat(字节值, 5, 4)), " %")
}
}
when 按钮_开灯.Click() {
if (低功耗蓝牙1.IsDeviceConnected) {
低功耗蓝牙1.WriteBytes(服务, 写入特征, false, 二进制1.FromHex("A5 01 01"))
}
}
收到 00 00 BC 41 00 00 34 42(23.5 和 45.0)显示「温度 23.5 ℃ 湿度 45 %」。
订阅时「有符号」填假,收到的是 0~255,二进制组件按原始字节解读;本例固件约定一次通知恰好包含 8 字节;其他协议的业务帧可能跨通知,需要按协议缓存。
Socket:Modbus RTU 读寄存器
很多 Wi-Fi/以太网转 RS485 模块(透传模式)把 TCP 收到的字节原样发给 Modbus 设备。
读 1 号从站从 0 开始的 2 个保持寄存器:请求 01 03 00 00 00 02 + CRC16-MODBUS,
回复 01 03 04 + 4 个字节数据 + CRC,共 9 个字节:
when 按钮_读取.Click() {
var 请求 = 二进制1.Build([1, 3, 二进制1.NumberToBytes(0, 2), 二进制1.NumberToBytes(2, 2)])
Socket客户端1.SendBytes(二进制1.AppendChecksum(请求, "CRC16-MODBUS"))
}
when Socket客户端1.BytesReceived(字节列表) {
if (length(字节列表) == 9 and 二进制1.VerifyChecksum(字节列表, "CRC16-MODBUS")) {
var 温度 = 二进制1.BytesToNumber(字节列表, 4, 2, true) / 10
var 湿度 = 二进制1.BytesToNumber(字节列表, 6, 2, false) / 10
标签1.Text = join("温度 ", 温度, " ℃ 湿度 ", 湿度, " %")
} else {
标签1.Text = join("回复不完整或校验不对:", 二进制1.ToHex(字节列表))
}
}
发出的请求是 01 03 00 00 00 02 C4 0B。寄存器值是高字节在前,二进制组件默认就是高位优先;
CRC16-MODBUS 按协议固定低字节在前,二进制组件已自动处理。
这是字段解析片段,假设回复已经完整组装;TCP 在任何网络下都可能分段。实际运行须先缓存并按 Modbus 地址、功能码和字节数组帧,还要处理异常回复(功能码高位为 1)。此处是 RTU 经 TCP 透传,不能直接用于原生 Modbus TCP(后者使用 MBAP 头,不带 RTU CRC)。
Socket:按帧头和长度拆帧
TCP 是字节流:设备连发两帧,App 可能一次收到两帧,也可能收到一帧半、下次再收到剩下的半帧。 可靠的做法是把收到的字节接到一个全局「缓冲」里,再从缓冲里一帧一帧地取。
协议:帧头 A5 5A + 数据长度 N(1 字节)+ N 个字节的数据 + 累加校验,一帧共 N+4 个字节:
global 缓冲 = []
global 帧数 = 0
when Socket客户端1.BytesReceived(字节列表) {
listAppend(缓冲, 字节列表)
拆帧()
}
procedure 拆帧() {
var 继续 = true
while (继续) {
var 头 = 二进制1.IndexOf(缓冲, [165, 90], 1)
if (头 == 0) {
if (length(缓冲) > 1) {
缓冲 = 二进制1.SubBytes(缓冲, length(缓冲), 1)
}
继续 = false
} else {
if (头 > 1) {
缓冲 = 二进制1.SubBytes(缓冲, 头, length(缓冲) - 头 + 1)
}
if (length(缓冲) < 4) {
继续 = false
} else {
var 帧长 = 缓冲[3] + 4
if (length(缓冲) < 帧长) {
继续 = false
} else {
var 帧 = 二进制1.SubBytes(缓冲, 1, 帧长)
缓冲 = 二进制1.SubBytes(缓冲, 帧长 + 1, length(缓冲) - 帧长)
if (二进制1.VerifyChecksum(帧, "SUM8")) {
帧数 = 帧数 + 1
标签1.Text = join("第 ", 帧数, " 帧,数据 ", 二进制1.ToHex(二进制1.SubBytes(帧, 4, 帧[3])))
}
}
}
}
}
}
要点:
- 找不到帧头时只留最后一个字节(它可能是被切开的帧头的前一半),其余丢掉,缓冲不会越攒越多;
- 帧头前面的杂字节先扔掉,保证缓冲总是从帧头开始;
- 不够一帧就停下等下次收到;够了就切出一帧、核对校验,再继续看剩下的还有没有完整帧。
把「处理一帧」的逻辑换成你自己的解析即可。同样的写法也适用于经典蓝牙:计时器里把「可接收字节数」个字节全部读出来接到缓冲,再调用拆帧。
WebSocket:接收二进制推送
服务器每秒推送一条二进制消息:2 字节序号 + 4 字节 float 电压。WebSocket 一条消息就是一整包:
when WebSocket客户端1.BytesReceived(字节列表) {
if (length(字节列表) == 6) {
标签1.Text = join("#", 二进制1.BytesToNumber(字节列表, 1, 2, false), " 电压 ", round(二进制1.BytesToFloat(字节列表, 3, 4) * 100) / 100, " V")
}
}
when 按钮_清零.Click() {
WebSocket客户端1.SendBytes(二进制1.FromHex("A5 00"))
}
收到 00 2A 40 53 33 33 显示「#42 电压 3.3 V」。
MQTT:发布和解析二进制负载
发布用「发布二进制消息」;收到时「收到消息时」的 Payload 就是字节列表,直接交给二进制组件解析:
when 按钮_读温度.Click() {
MQTT客户端1.PublishBytes("dev/001/cmd", 二进制1.AppendChecksum([165, 2], "SUM8"))
}
when MQTT客户端1.MessageReceived(主题, 负载, 消息, 保留标志, 重复标志) {
if (主题 == "dev/001/reply" and length(负载) == 5) {
if (负载[1] == 165 and 负载[2] == 2 and 二进制1.VerifyChecksum(负载, "SUM8")) {
标签1.Text = join("温度:", 二进制1.BytesToNumber(负载, 3, 2, true) / 10, " ℃")
}
}
}
收到 A5 02 00 EB 92 时显示「温度:23.5 ℃」。
继电器板:一个字节控制 8 路开关
继电器板常用一个字节表示 8 路开关,第 0 位是第 1 路。协议:A5 10 + 状态字节 + 累加校验。
按钮切换第 3 路,其余几路保持不变:
global 状态 = 0
procedure 切换(路) {
状态 = 二进制1.SetBit(状态, 路 - 1, not 二进制1.GetBit(状态, 路 - 1))
蓝牙客户端1.SendBytes(二进制1.AppendChecksum([165, 16, 状态], "SUM8"))
标签1.Text = join("状态字节 ", 二进制1.ToHex([状态]))
}
when 按钮_第3路.Click() {
切换(3)
}
第一次点显示「状态字节 04」,再点变回「00」。设备回报状态时,用「取位」逐位判断每一路是开是关。
Web 接口:用 Base64 传二进制
HTTP 接口里不方便直接放字节,通用做法是转成 Base64 文本。把一段原始采样数据上传到服务器:
when 按钮_上传.Click() {
var 数据 = 二进制1.Build([1, 2, 二进制1.NumberToBytes(1500, 2), 二进制1.FloatToBytes(23.5, 4)])
Web客户端1.Url = join("https://example.com/api/raw?b64=", Web客户端1.UriEncode(二进制1.BytesToBase64(数据)))
Web客户端1.Get()
}
这段数据的 Base64 是 AQIF3EG8AAA=。服务器端用任何语言的 Base64 解码都能还原成原来的 8 个字节。
从零跑通:TCP 温湿度与开关控制
这个主案例把连接 → 拼帧 → 发送 → 接收缓存 → 拆帧 → 校验 → 解析 → 确认设备状态 → 超时与断开串在同一个项目里。无需硬件;电脑上的模拟设备就是通信对端。
准备对端与界面
下载 TCP 模拟设备 binary_device.py,用 Python 3 运行:
python binary_device.py --fixed
不需要安装第三方库。固定模式回复温度 23.5 ℃、湿度 45 %;省略 --fixed 则生成随机温湿度。它还会轮流发送半帧、两帧连发和校验错误帧。手机和电脑连同一个局域网,服务器地址填电脑的局域网 IP,不能填手机自己的 127.0.0.1。电脑须允许入站 TCP 8888。
| 组件名称 | 设置 |
|---|---|
文本输入框_地址 |
默认文本:192.168.1.10,运行时改成电脑 IP,不含协议和端口 |
二进制1 |
高位优先:真;文本编码:UTF-8 |
Socket客户端1 |
服务器地址:电脑 IP;服务器端口:8888;超时毫秒:3000(连接超时) |
计时器1 |
计时间隔:1000;计时启用:真;始终计时:假 |
按钮_连接、按钮_断开 |
主动连接和结束会话 |
按钮_读取、按钮_开灯、按钮_关灯 |
发出命令 |
标签_连接、标签_发送、标签_温湿度、标签_灯、标签_推送 |
显示连接、原始发送、解析结果、设备确认和错误计数 |
协议统一为 A5 5A + 数据长度 N + 数据 + SUM8,一帧共 N+4 字节。SUM8 覆盖从帧头到数据最后一个字节。本案例数据长度限制为 1~32;换用其他协议时必须相应修改上限。
| 方向 | 数据字段 | 完整帧示例 |
|---|---|---|
| 请求读取 | 01 |
A5 5A 01 01 01 |
| 温湿度回复 | 81 + 温度高字节 + 温度低字节 + 湿度 |
A5 5A 04 81 00 EB 2D 9C |
| 请求开灯 | 02 01 |
A5 5A 02 02 01 04 |
| 开灯确认 | 82 01 |
A5 5A 02 82 01 84 |
| 请求关灯 | 02 00 |
A5 5A 02 02 00 03 |
| 关灯确认 | 82 00 |
A5 5A 02 82 00 83 |
| 主动推送 | 90 + 一字节序号 |
A5 5A 02 90 01 92 |
温度是有符号 16 位整数除以 10;00 EB 为 235。推送序号每 256 次回绕。发送成功只表示已交给传输组件;收到确认帧才更新灯状态。
完整积木:连接、收发、拆帧与超时
新建项目后,先按上表拖入并重命名组件。在本段代码块图上点“复制代码块”,切到编辑器的代码块视图,点击空白工作区后按 Ctrl+V(macOS 用 Command+V)。不要只复制原始伪代码文本到工作区;重名事件需先删除旧块再粘贴。
下面是一整段完整积木,包含连接、发送、超时、缓存、拆帧和结果解析,可整段复制。每种事件只保留一个处理入口。一次只等待一条命令,避免没有请求序号的协议把并发回复混在一起。
global 缓冲 = []
global 已连接 = false
global 正在连接 = false
global 等待命令 = 0
global 等待秒 = 0
global 残留秒 = 0
global 坏帧数 = 0
when 按钮_连接.Click() {
if (not 已连接 and not 正在连接) {
正在连接 = true
标签_连接.Text = "正在连接,请稍候"
Socket客户端1.ServerAddress = 文本输入框_地址.Text
Socket客户端1.ServerPort = 8888
Socket客户端1.Connect()
}
}
when Socket客户端1.Connected() {
正在连接 = false
已连接 = true
缓冲 = []
等待命令 = 0
标签_连接.Text = "已连接,可以读取或控制"
}
when Socket客户端1.ConnectFailed(消息) {
清理连接()
标签_连接.Text = join("连接失败:", 消息)
}
when 按钮_断开.Click() {
Socket客户端1.Disconnect()
清理连接()
}
when Socket客户端1.Disconnected() {
清理连接()
}
when Socket客户端1.RemoteConnectionClosed() {
清理连接()
}
procedure 清理连接() {
正在连接 = false
已连接 = false
缓冲 = []
等待命令 = 0
等待秒 = 0
残留秒 = 0
标签_连接.Text = "已断开;重新连接后再发送"
}
when Screen1.ErrorOccurred(组件, 函数名称, 错误编号, 消息) {
if (函数名称 == "Connect") {
清理连接()
}
标签_连接.Text = join("运行错误:", 函数名称, " / ", 消息)
}
procedure 发送命令(数据, 回复命令) {
if (已连接) {
if (等待命令 == 0) {
var 帧 = 二进制1.AppendChecksum(二进制1.Build([165, 90, length(数据), 数据]), "SUM8")
等待命令 = 回复命令
等待秒 = 4
Socket客户端1.SendBytes(帧)
标签_发送.Text = join("发送 ", 二进制1.ToHex(帧), ";等待设备确认")
} else {
标签_发送.Text = "上一条命令尚未完成,请稍候"
}
} else {
标签_发送.Text = "请先连接"
}
}
when 按钮_读取.Click() {
发送命令([1], 129)
}
when 按钮_开灯.Click() {
发送命令([2, 1], 130)
}
when 按钮_关灯.Click() {
发送命令([2, 0], 130)
}
when 计时器1.Timer() {
if (等待命令 != 0) {
等待秒 = 等待秒 - 1
if (等待秒 <= 0) {
Socket客户端1.Disconnect()
清理连接()
标签_发送.Text = "回复超时,状态未确认;重新连接后读取状态"
}
}
if (length(缓冲) > 0) {
残留秒 = 残留秒 + 1
if (残留秒 >= 4) {
缓冲 = []
残留秒 = 0
标签_连接.Text = "残帧等待超时,已清空接收缓存"
}
}
}
when Socket客户端1.BytesReceived(字节列表) {
if (已连接) {
listAppend(缓冲, 字节列表)
残留秒 = 0
if (length(缓冲) > 4096) {
缓冲 = []
标签_连接.Text = "接收缓存超过 4096 字节,已清空;检查协议或发送速率"
} else {
拆通信帧()
}
}
}
procedure 去掉前面(数量) {
if (数量 >= length(缓冲)) {
缓冲 = []
} else {
缓冲 = 二进制1.SubBytes(缓冲, 数量 + 1, length(缓冲) - 数量)
}
}
procedure 拆通信帧() {
var 继续 = true
while (继续) {
var 头 = 二进制1.IndexOf(缓冲, [165, 90], 1)
if (头 == 0) {
if (length(缓冲) > 1) {
缓冲 = 二进制1.SubBytes(缓冲, length(缓冲), 1)
}
继续 = false
} else {
if (头 > 1) {
去掉前面(头 - 1)
}
if (length(缓冲) < 3) {
继续 = false
} else {
var 数据长 = 缓冲[3]
if (数据长 < 1 or 数据长 > 32) {
坏帧数 = 坏帧数 + 1
去掉前面(1)
} else {
var 帧长 = 数据长 + 4
if (length(缓冲) < 帧长) {
继续 = false
} else {
var 帧 = 二进制1.SubBytes(缓冲, 1, 帧长)
if (二进制1.VerifyChecksum(帧, "SUM8")) {
去掉前面(帧长)
处理通信帧(帧)
} else {
坏帧数 = 坏帧数 + 1
去掉前面(1)
}
}
}
}
}
}
}
procedure 处理通信帧(帧) {
var 命令 = 帧[4]
var 有效 = false
if (命令 == 129 and 帧[3] == 4) {
标签_温湿度.Text = join("温度 ", 二进制1.BytesToNumber(帧, 5, 2, true) / 10, " ℃;湿度 ", 帧[7], " %")
有效 = true
} else if (命令 == 130 and 帧[3] == 2) {
if (帧[5] == 0 or 帧[5] == 1) {
标签_灯.Text = choose(帧[5] == 1, "设备确认:灯开", "设备确认:灯关")
有效 = true
}
} else if (命令 == 144 and 帧[3] == 2) {
标签_推送.Text = join("推送序号 ", 帧[5], ";坏帧 ", 坏帧数)
有效 = true
}
if (有效 and 命令 == 等待命令) {
等待命令 = 0
等待秒 = 0
标签_发送.Text = "已收到有效回复,本次命令完成"
}
}
计时器的 4 个刻度约为 3~4 秒,并非精确毫秒计时。超时后断开会话,避免迟到的旧回复被下一次同类命令误认;生产协议可增加请求序号。不要自动重复开关命令,除非协议明确保证重复执行安全。
先校验完整帧,再按命令核对数据长度,最后才取字段。长度或校验错误时前移一个字节重新找帧头;半帧保留,完整帧可以连续处理。坏长度导致等待时,由残帧超时清掉。4096 字节是教学项目的缓存上限,超出会丢数据,应按设备吞吐量调整或采用分批解析。
闭环验收
| 操作 | 电脑端 | App 端通过标准 |
|---|---|---|
| 连接后点读取 | 收到 A5 5A 01 01 01,回复 A5 5A 04 81 00 EB 2D 9C |
显示 23.5 ℃、45 %,等待状态结束 |
| 点开灯,再点关灯 | 返回 82 01、82 00 确认帧 |
只有有效回复到达后才显示设备确认开/关 |
| 保持连接约 15 秒 | 轮流发送拆开、合并和坏校验数据 | 推送继续更新,坏帧计数增加,无越界解析 |
| 点断开,再连接并读取 | 重新接受连接 | 旧缓冲已清空,读取再次成功 |
| 关闭模拟设备 | 连接结束 | 显示已断开,后续发送提示先连接 |
| 连接一个收数据但不回复的对端 | 没有业务确认 | 约 3~4 秒后提示超时,灯状态保持上次确认值 |
系统可能把两次 sendall 合并到一次接收,不能用事件触发次数判断半包测试是否通过。需要固定分段输入时,用下一节的离线测试。
离线验证拆帧,无需网络
保留上面的过程,另加 按钮_测试拆帧。此测试直接注入接收缓存,不需要调用连接方法:
when 按钮_测试拆帧.Click() {
缓冲 = []
坏帧数 = 0
listAppend(缓冲, 二进制1.FromHex("99 A5"))
拆通信帧()
listAppend(缓冲, 二进制1.FromHex("5A 04 81 00"))
拆通信帧()
listAppend(缓冲, 二进制1.FromHex("EB 2D 9C A5 5A 02 82 01 84 A5 5A 02 90 01 93"))
拆通信帧()
标签_连接.Text = join("测试完成;坏帧 ", 坏帧数, ";残留字节 ", length(缓冲))
}
第一段应保留单字节 A5,第二段仍不足一帧,第三段补齐温湿度帧、接上一帧开灯确认,最后加一帧坏校验推送。最终温湿度为 23.5 ℃、45 %,灯显示开,坏帧数为 1。坏帧后的最后一个字节暂留为可能的帧头,计时器随后会清掉。
WebSocket 闭环:二进制回显
下载 HTTP / WebSocket 测试服务 binary_transport.py,运行 python binary_transport.py,无需第三方库。此脚本用于局域网教学联调,单条消息或 HTTP 请求体上限为 4096 字节;它原样返回收到的数据。TCP 模拟设备仍使用 8888,此服务使用 8080,可以同时启动。
拖入 WebSocket客户端1、二进制1、文本输入框_地址、按钮_连接、按钮_发送、按钮_断开、标签1。地址输入框填写电脑 IP(不含协议和端口),高位优先设为真。
when 按钮_连接.Click() {
WebSocket客户端1.Url = join("ws://", 文本输入框_地址.Text, ":8080/ws")
WebSocket客户端1.Connect()
}
when WebSocket客户端1.Connected() {
标签1.Text = "已连接;点发送测试二进制回显"
}
when 按钮_发送.Click() {
if (WebSocket客户端1.IsConnected) {
WebSocket客户端1.SendBytes(二进制1.AppendChecksum([165, 2, 0, 235], "SUM8"))
}
}
when Screen1.ErrorOccurred(组件, 函数名称, 错误编号, 消息) {
标签1.Text = join("运行错误:", 函数名称, " / ", 消息)
}
when WebSocket客户端1.BytesReceived(字节列表) {
if (length(字节列表) == 5) {
if (字节列表[1] == 165 and 字节列表[2] == 2 and 二进制1.VerifyChecksum(字节列表, "SUM8")) {
标签1.Text = join("回显确认:", 二进制1.ToHex(字节列表), ";数值 ", 二进制1.BytesToNumber(字节列表, 3, 2, true) / 10)
} else {
标签1.Text = "回显帧校验或命令不符"
}
} else {
标签1.Text = "回显长度不符"
}
}
when WebSocket客户端1.ConnectFailed(消息) {
标签1.Text = join("连接失败:", 消息)
}
when WebSocket客户端1.Disconnected(状态码, 原因) {
标签1.Text = join("已断开:", 状态码, " / ", 原因)
}
when 按钮_断开.Click() {
WebSocket客户端1.Disconnect()
}
点击发送,对端打印 A5 02 00 EB 92,App 显示同样字节和数值 23.5;断开再连接可以重复。本例验证的是传输与解析,回显不会执行设备命令。WebSocket 层会重组协议分片后交付完整消息;如果设备业务上分多条消息发送一帧,仍需要业务缓存。二进制消息同时触发文本事件时,只处理字节事件,避免重复执行业务。
MQTT 闭环:两台客户端的请求与回复
MQTT 需要可访问的 Broker。用两台手机或两个独立客户端运行下面的 A、B 项目:A 是控制端,B 扮演设备。两端拖入 MqttClient1、二进制1、文本输入框_地址、按钮_连接、按钮_断开、标签1;A 再加 按钮_读取。地址输入框填写 Broker 主机名或 IP。下面代码使用 TCP、端口 1883;加密或非默认端口请同步修改连接事件并填写属性面板账号。两端使用相同服务器、端口和账号,ClientID 分别为 binary-lab-a、binary-lab-b,不要重复。
协议属性按 Broker 配置选 TCP 或 TLS,服务器填主机名,不带 tcp:// 前缀。先连接 B 再连接 A。主题固定为 lab/binary/001/cmd、lab/binary/001/reply;多组测试将 001 换成自己的唯一设备编号。回复使用字节负载,不能解析 UTF-8 的 Message 字段。
没有 Broker:在电脑启动本地 MQTT 服务
已有可用 Broker 时跳过本节。否则先安装 Eclipse Mosquitto,下载 binary-lab.conf,在文件所在目录运行:
mosquitto -c binary-lab.conf -v
配置内容可以直接复制保存为 binary-lab.conf:
listener 1883 0.0.0.0
allow_anonymous true
persistence false
这份配置仅用于可信局域网联调:监听 1883,允许匿名连接,不持久化。两台手机的地址输入框都填电脑 IP;用户名和密码留空。binary_transport.py 只提供 HTTP/WebSocket,不能当作 MQTT Broker。若系统已运行另一份 Broker,先使用现有服务或改一个空闲端口,并同步修改两端积木的端口值。监听与匿名配置见 Mosquitto 配置参考,-c、-v 参数见 启动命令参考。
A:控制端
when 按钮_连接.Click() {
MqttClient1.Broker = 文本输入框_地址.Text
MqttClient1.Port = 1883
MqttClient1.Protocol = "TCP"
MqttClient1.ClientID = "binary-lab-a"
MqttClient1.Connect(true)
}
when MqttClient1.ConnectionStateChanged(新状态, 状态文本) {
标签1.Text = 状态文本
if (MqttClient1.IsConnected) {
MqttClient1.Subscribe("lab/binary/001/reply", 0)
}
}
when 按钮_读取.Click() {
if (MqttClient1.IsConnected) {
MqttClient1.PublishBytes("lab/binary/001/cmd", 二进制1.AppendChecksum([165, 2], "SUM8"))
标签1.Text = "已发请求,等待 B 回复"
}
}
when MqttClient1.MessageReceived(主题, 负载, 消息, 保留标志, 重复标志) {
if (主题 == "lab/binary/001/reply" and not 保留标志) {
if (length(负载) == 5) {
if (负载[1] == 165 and 负载[2] == 130 and 二进制1.VerifyChecksum(负载, "SUM8")) {
标签1.Text = join("设备回复:", 二进制1.ToHex(负载), ";温度 ", 二进制1.BytesToNumber(负载, 3, 2, true) / 10, " ℃")
}
}
}
}
when MqttClient1.ErrorOccurred(操作名, 错误代码, 错误信息) {
标签1.Text = join(操作名, ":", 错误信息)
}
when 按钮_断开.Click() {
MqttClient1.Disconnect()
}
B:模拟设备
when 按钮_连接.Click() {
MqttClient1.Broker = 文本输入框_地址.Text
MqttClient1.Port = 1883
MqttClient1.Protocol = "TCP"
MqttClient1.ClientID = "binary-lab-b"
MqttClient1.Connect(true)
}
when MqttClient1.ConnectionStateChanged(新状态, 状态文本) {
标签1.Text = 状态文本
if (MqttClient1.IsConnected) {
MqttClient1.Subscribe("lab/binary/001/cmd", 0)
}
}
when MqttClient1.MessageReceived(主题, 负载, 消息, 保留标志, 重复标志) {
if (主题 == "lab/binary/001/cmd" and not 保留标志) {
if (length(负载) == 3) {
if (负载[1] == 165 and 负载[2] == 2 and 二进制1.VerifyChecksum(负载, "SUM8")) {
标签1.Text = join("收到请求 ", 二进制1.ToHex(负载))
MqttClient1.PublishBytes("lab/binary/001/reply", 二进制1.AppendChecksum([165, 130, 0, 235], "SUM8"))
}
}
}
}
when MqttClient1.ErrorOccurred(操作名, 错误代码, 错误信息) {
标签1.Text = join(操作名, ":", 错误信息)
}
when 按钮_断开.Click() {
MqttClient1.Disconnect()
}
A 发送 A5 02 A7,B 显示收到请求并回复 A5 82 00 EB 12,A 显示 23.5 ℃,这才完成请求—设备处理—回复闭环。连接事件不代表订阅一定完成,首次测试等两端状态稳定后再点读取;丢失时重试读取。内置「发布二进制消息」固定 QoS 0、保留标志为假;本例订阅也使用 QoS 0,不保证送达;B 离线时不能把发布成功当作命令执行成功。需要控制真实执行动作时,增加业务请求序号、超时、确认和重复请求去重;不要仅凭 MQTT 的重复标志判断业务是否已执行。
HTTP 闭环:Base64 上传并还原回复
仍使用 binary_transport.py,网址为 http://电脑IP:8080/binary。这个接口接收纯 Base64 文本请求体,原样解码后再编码回复;不是 JSON 接口。实际接口若要求 JSON,应按其字段协议封装。
拖入 Web客户端1、二进制1、文本输入框_地址、按钮_上传、标签1;地址输入框填写电脑 IP(不含协议和端口)。Web 的保存响应属性设为假,超时设为 5000 毫秒,高位优先设为真。
when 按钮_上传.Click() {
var 数据 = 二进制1.Build([1, 2, 二进制1.NumberToBytes(1500, 2), 二进制1.FloatToBytes(23.5, 4)])
Web客户端1.Url = join("http://", 文本输入框_地址.Text, ":8080/binary")
Web客户端1.RequestHeaders = [["Content-Type", "text/plain; charset=utf-8"]]
Web客户端1.PostText(二进制1.BytesToBase64(数据))
}
when Web客户端1.GotText(URL网址, 响应代码, 响应类型, 响应内容) {
if (响应代码 == 200) {
var 字节 = 二进制1.Base64ToBytes(响应内容)
if (length(字节) == 8) {
标签1.Text = join("回复 ", 二进制1.ToHex(字节), ";整数 ", 二进制1.BytesToNumber(字节, 3, 2, false), ";小数 ", 二进制1.BytesToFloat(字节, 5, 4))
} else {
标签1.Text = "解码后长度不符"
}
} else {
标签1.Text = join("HTTP 错误:", 响应代码, " / ", 响应内容)
}
}
when Web客户端1.TimedOut(URL网址) {
标签1.Text = "HTTP 请求超时"
}
when Screen1.ErrorOccurred(组件, 函数名称, 错误编号, 消息) {
标签1.Text = join("错误:", 函数名称, " / ", 消息)
}
把地址输入框中的 IP 改成电脑地址。请求体为 AQIF3EG8AAA=,服务端打印 01 02 05 DC 41 BC 00 00,App 还原为整数 1500、小数 23.5。可先在电脑验证接口:
curl -X POST -H 'Content-Type: text/plain' --data 'AQIF3EG8AAA=' http://127.0.0.1:8080/binary
预期回复仍为 AQIF3EG8AAA=;把请求体换成 %%%,应返回 HTTP 400。文件或图片较大时,应使用接口支持的文件上传方式;Base64 会增加约三分之一体积,不适合无限放大负载。
蓝牙案例补齐连接与接收前提
经典蓝牙:先配对、连接,再启用接收
先在系统设置配对 HC-05/HC-06 等支持串口协议的设备;准备设备端固件按定长温湿度协议发送数据,并按小车控制协议执行命令。不同案例使用不同协议,不要让同一份固件混用帧格式。
拖入 列表选择器1、蓝牙客户端1、计时器1、按钮_断开、标签1。计时器初始禁用、间隔 200 毫秒;在已有收发案例外增加:
when 列表选择器1.BeforePicking() {
列表选择器1.Elements = 蓝牙客户端1.AddressesAndNames
}
when 列表选择器1.AfterPicking() {
if (蓝牙客户端1.Connect(列表选择器1.Selection)) {
计时器1.TimerEnabled = true
标签1.Text = "已连接;可以发送与接收"
} else {
标签1.Text = "连接失败;检查设备是否支持经典蓝牙串口"
}
}
when 按钮_断开.Click() {
计时器1.TimerEnabled = false
蓝牙客户端1.Disconnect()
}
获得系统蓝牙权限后再打开列表;手机没有蓝牙、蓝牙关闭或权限被拒绝时,通过屏幕错误事件显示原因。发送事件也要判断是否已连接。验收时先看到连接成功,再发送速度帧,在设备端核对 A5 32 CE 59;反向由设备发送 A5 FF 38 2D 09,App 显示 -20 ℃、45 %。使用流式拆帧时,把实际可接收字节全部加入缓存,而不是每次固定取 5 字节。
BLE:发现设备、订阅正确特征,再解析通知
低功耗蓝牙案例的 UUID 采用 UART 服务示例;设备固件必须实际提供这些服务和特征:写入特征支持写,通知特征支持通知,并以低位优先发送两个 float。UUID 相同并不代表所有 UART 固件都发送这种数据格式。
增加地址输入框 文本输入框_设备地址、按钮_扫描、按钮_连接,先扫描,再从发现列表核对设备地址后连接:
when 按钮_扫描.Click() {
低功耗蓝牙1.StartScanning()
}
when 低功耗蓝牙1.DeviceFound() {
标签1.Text = 低功耗蓝牙1.DeviceList
}
when 按钮_连接.Click() {
低功耗蓝牙1.StopScanning()
低功耗蓝牙1.ConnectWithAddress(文本输入框_设备地址.Text)
}
连接后的订阅和按钮写入使用前面的 BLE 案例。接收时必须核对服务 UUID、特征 UUID 和 8 字节长度,再解析 float;写入前确认已连接。BLE 的一次通知有自己的边界,但完整业务帧可能跨多个通知,不能把“收到通知”直接当成“收到完整业务帧”。发送超过设备或协商 MTU 允许的长度时,需按照设备协议分块,不能直接套用 TCP 的发送习惯。
从教学案例迁移到真实设备
| 核对项 | 完成标准 |
|---|---|
| 协议版本与帧格式 | 有明确帧头、长度范围、字段位置和响应格式;至少一组设备端已验证的请求/回复 |
| 数字 | 字节顺序、有符号范围、倍率和 float 字节数双方一致 |
| 校验 | 算法、初值、覆盖范围、输出顺序双方一致;用已知帧逐字节核对 |
| 连接 | 只有连接成功才能发送;订阅或通知启用后才能等待数据 |
| 消息边界 | TCP/经典蓝牙处理半包粘包;BLE/消息协议核对业务分包方式 |
| 业务完成 | 用匹配的确认回复更新状态,显示超时,区分发送成功与设备执行成功 |
| 重连 | 清空旧缓存和等待状态;重新订阅;有请求序号时避免旧回复匹配新请求 |
| 故障 | 测试坏校验、短帧、非法长度、断网、对端离线和拒绝授权 |
| 证据 | 保存发送十六进制、收到十六进制、对端日志和界面最终值 |
本页的 SUM8 是教学协议,不具有防篡改能力。联网设备的身份认证、TLS、权限和业务授权由设备与服务协议决定;二进制编码本身不提供这些能力。
常见问题
| 现象 | 原因 | 解决 |
|---|---|---|
| 收到的温度是 65516 这种大数 | 负数按无符号读了 | 「字节转整数」的「有符号」选真 |
| 数字对不上,差了很多倍 | 字节顺序反了 | 单片机直接发内存里的数字多是低字节在前,把「高位优先」设为假 |
| float 解出来是极小或极大的怪数 | 字节顺序反了,或起始位置错一位 | 先用「字节转十六进制」看原始数据,再核对起始位置 |
| 校验总是不对 | 校验算法、初值或覆盖范围和设备不一致 | 用参考页里 123456789 的检验值核对设备端实现;确认校验算的是哪几个字节 |
| 拼出来的帧多了几个字节 | 文本框里的 "12" 按文本转成了两个字节 |
数字要当一个字节时放进列表,或先做一次数学运算 |
| Socket 收到的数据时多时少 | TCP 字节流会拆包、粘包 | 用拆帧的写法 |
| 中文显示乱码 | 设备用 GBK 编码 | 「文本编码」设为 GBK |
| 报错「超出一个字节的范围」 | 列表里放了大于 255 的数 | 多字节数字先用「整数转字节」 |
大编号保留原始字节
卡号和设备编号通常只是标识,不需要做加减乘除。保持为字节或十六进制文本,就不会丢高位、丢前导零或误读成负数:
when 按钮1.Click() {
var 编号 = 二进制1.FromHex("00 12 34 56 78 9A BC DE")
var 编号文本 = 二进制1.ToHex(编号)
var 原样字节 = 二进制1.FromHex(编号文本)
标签1.Text = 二进制1.ToHex(原样字节)
}
显示 00 12 34 56 78 9A BC DE;原样字节 可以直接发送,保存时将 编号文本 放进本地数据库。需要无空格格式时在界面属性设置“十六进制分隔符”为空文本。不要把这个编号先转成小数数字再转回来。
普通传感器的 8/16/32 位数值仍使用整数转换。8 字节数字转换超过 ±9007199254740991 时现在会明确报错,避免跨平台静默失真;若设备要求完整 64 位计算,应使用支持精确整数的对端或明确的字节算法,不能把普通数字块当成任意精度整数。
平台说明
二进制组件在安卓与鸿蒙上行为一致,结果逐字节相同;二进制工具积木在两个平台上写法相同;各通信组件须使用包含对应运行时的伴侣或安装包,并按其平台说明核对实际支持。 经典蓝牙、串口通信器取决于各平台的系统支持,详见各组件文档。
更多入门案例见二进制组件使用指导。
扫码添加客服咨询