手机当上位机:用二进制协议和设备收发数据

« 返回首页

做硬件的用户常问:单片机那边的协议是「A5 5A 开头、带长度、带校验」的一串字节,App Inventor 能不能按这种格式收发? 收到的数据时多时少、偶尔一帧被拆成两次,怎么才能稳定解析?

能实现,不需要拓展。 用到两样现成的东西:

没有硬件也没关系:本文附一个 Python 脚本,在电脑上扮演设备。它会回复命令,还会故意把一帧拆成两半发、把两帧粘在一起发、夹一个校验错误的坏帧, 正好检验 App 的解析够不够稳。等真设备做好了,把服务器地址换成设备的 IP 即可。

协议

帧格式(所有帧都一样):

帧头 长度 数据 校验
A5 5A 数据的字节数 N N 个字节 前面所有字节相加取低 8 位

数据的第 1 个字节是命令:

方向 数据 含义
App → 设备 01 读温湿度
设备 → App 81 + 温度(2 字节,乘 10,有符号,高字节在前)+ 湿度(1 字节) 温湿度回复
App → 设备 02 + 01/00 开灯 / 关灯
设备 → App 82 + 当前状态 开关灯回复
设备 → App 90 + 序号 每 2 秒主动推送一次

例如「读温湿度」命令就是 A5 5A 01 01 01:帧头、长度 1、命令 01、校验(A5+5A+01+01 = 0x101,取低 8 位 01)。

准备

  1. 电脑装好 Python 3(不需要装任何库),下载 binary_device.py,运行 python binary_device.py。 窗口显示「模拟设备已启动,端口 8888」和电脑 IP。电脑和手机连同一个 Wi-Fi;IP 不对时在电脑上用 ipconfig(Windows)或 ifconfig 查局域网 IP。
  2. App 里拖一个 Socket客户端(服务器地址填电脑 IP,端口 8888)、一个二进制组件, 三个按钮(按钮_读取、按钮_开灯、按钮_关灯)和四个标签(标签_发送、标签_温湿度、标签_灯、标签_推送)。

实验一:发出第一条命令

先只做「发」:打开 App 就连接,点按钮把「读温湿度」命令拼好发出去。

when Screen1.Initialize() {
  Socket客户端1.Connect()
}

when 按钮_读取.Click() {
  var 帧 = 二进制1.AppendChecksum(二进制1.Build([165, 90, 1, 1]), "SUM8")
  Socket客户端1.SendBytes(帧)
  标签_发送.Text = join("发送 ", 二进制1.ToHex(帧))
}

跑起来看:点「读取」,标签显示「发送 A5 5A 01 01 01」,电脑窗口同时打印「收到 A5 5A 01 01 01」和一行「回复 A5 5A 04 81 …」。 两边字节一致,说明拼包对了。窗口里打印「校验不对,丢弃」就回头检查拼接的内容。

实验二:拆包并解析回复

设备的回复和推送会源源不断地到来。TCP 不保证「发一次收一次」,所以不能拿到一段就当一帧, 要先接到全局「缓冲」里,再按帧头和长度一帧一帧地取。把实验一的代码换成下面这一整段:

global 缓冲 = []
global 丢弃 = 0

when Screen1.Initialize() {
  Socket客户端1.Connect()
}

procedure 发送命令(数据) {
  var 帧 = 二进制1.AppendChecksum(二进制1.Build([165, 90, length(数据), 数据]), "SUM8")
  Socket客户端1.SendBytes(帧)
  标签_发送.Text = join("发送 ", 二进制1.ToHex(帧))
}

when 按钮_读取.Click() {
  发送命令([1])
}

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")) {
            处理一帧(帧)
          } else {
            丢弃 = 丢弃 + 1
          }
        }
      }
    }
  }
}

procedure 处理一帧(帧) {
  var 命令 = 帧[4]
  if (命令 == 129) {
    标签_温湿度.Text = join("温度 ", 二进制1.BytesToNumber(帧, 5, 2, true) / 10, " ℃  湿度 ", 帧[7], " %")
  } else if (命令 == 130) {
    标签_灯.Text = choose(帧[5] == 1, "灯:开", "灯:关")
  } else if (命令 == 144) {
    标签_推送.Text = join("推送序号 ", 帧[5], "  丢弃坏帧 ", 丢弃)
  }
}

跑起来看:点「读取」,标签_温湿度 显示和电脑窗口「回复」那一行对应的温湿度。例如回复 A5 5A 04 81 FF 38 2D … 显示「温度 -20 ℃ 湿度 45 %」:FF 38 按有符号是 -200,除以 10 得 -20。温度是随机的,多点几次看看零下的情况。

命令字节 129、130、144 就是十六进制的 81、82、90。拆帧的三个要点:

  • 找不到帧头时只留最后一个字节(它可能是被切开的帧头的前一半),缓冲不会越攒越多;
  • 帧头前面的杂字节先扔掉,缓冲总是从帧头开始;
  • 不够一帧就停下等下一段数据;够了就切出一帧核对校验,再看剩下的还有没有完整帧。

实验三:开灯关灯

再加两个按钮。命令帧直接写成列表,和实验一一样拼好就发:

when 按钮_开灯.Click() {
  Socket客户端1.SendBytes(二进制1.AppendChecksum([165, 90, 2, 2, 1], "SUM8"))
}

when 按钮_关灯.Click() {
  Socket客户端1.SendBytes(二进制1.AppendChecksum([165, 90, 2, 2, 0], "SUM8"))
}

跑起来看:点「开灯」,电脑窗口打印「收到 A5 5A 02 02 01 04」,App 的 标签_灯 变成「灯:开」。 这个标签是设备回复后才改的,所以它显示的是设备的真实状态,不是按钮按了什么。

实验四:看半包、粘包和坏帧

什么都不用改,看 标签_推送 和电脑窗口。模拟设备每 2 秒推送一次,轮流制造三种情况:

电脑窗口 实际发生的事 App 显示
推送(拆成两半) 一帧分两次到达:第一次只有 3 个字节 等第二段到了才显示这个序号
推送(两帧粘在一起) 两帧一次到达 连着处理两帧,序号跳到后一个
推送(校验错误的坏帧) 最后一个字节被改坏 这一帧被丢弃,「丢弃坏帧」加 1,序号跳过它

跑起来看:十几秒后显示类似「推送序号 5 丢弃坏帧 1」,序号从 3 直接到 5,4 号是那个坏帧。 如果去掉拆帧、直接拿每次收到的字节当一帧解析,「拆成两半」那次会解析出错,「粘在一起」那次会丢掉后一帧。

常见问题

现象 原因 解决
连不上 IP 不对、不在同一 Wi-Fi,或电脑防火墙拦了 8888 端口 用 ipconfig 查局域网 IP;防火墙放行 Python
电脑窗口显示「校验不对,丢弃」 拼帧时长度或内容写错 把 标签_发送 的内容和协议表逐个字节对一下
温度显示 6533.6 这种大数 「字节转整数」的「有符号」选成了假 温度可能为负,选真
解析偶尔出错 没有拆帧,直接把每次收到的字节当一帧 用实验二的拆帧写法
换成真设备后数字对不上 设备的字节顺序或校验算法不同 按设备协议文档改「高位优先」和校验类型;先用「字节转十六进制」看原始字节

平台说明

  • 安卓、鸿蒙:本文所有积木两边写法相同,二进制组件的结果逐字节一致。
  • 换成蓝牙、WebSocket 或 MQTT 连接设备时,只需要换收发积木,拼帧、拆帧、解析的部分不用改,见二进制数据专题。

相关文档

文档反馈