二进制数据专题:蓝牙、Socket、WebSocket、MQTT 收发字节

« 返回通信连接组件 · 二进制组件参考 · 二进制组件使用指导

二进制数据专题

单片机、传感器模块、工控设备之间传数据,大多不是发一句文本,而是发一串字节: 「帧头 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 位计算,应使用支持精确整数的对端或明确的字节算法,不能把普通数字块当成任意精度整数。

平台说明

二进制组件在安卓与鸿蒙上行为一致,结果逐字节相同;二进制工具积木在两个平台上写法相同;各通信组件须使用包含对应运行时的伴侣或安装包,并按其平台说明核对实际支持。 经典蓝牙、串口通信器取决于各平台的系统支持,详见各组件文档。

更多入门案例见二进制组件使用指导。

文档反馈