CCSDS_study project
255
test/Tianwen-1-frame-analysis-v2.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Tianwen-1-frame-analysis-v2.py 新手说明
|
||||
|
||||
这个文件是一个“天问一号遥测帧分析脚本”。如果用新手的话说,它做的事情就是:
|
||||
|
||||
> 读取一大包二进制遥测数据,把它拆成一帧一帧,再按航天器 ID、虚拟信道和 APID 分类,最后把不同数据字段画成图片保存下来,方便人工观察这些遥测字段怎么变化。
|
||||
|
||||
## 输入数据
|
||||
|
||||
脚本读取的数据文件是:
|
||||
|
||||
```text
|
||||
/home/zjz/CCSDS_study/Tianwen/tianwen1_frames_20200730.u8
|
||||
```
|
||||
|
||||
这个文件是一串连续的原始字节。脚本认为每 220 个字节是一帧 AOS frame,所以会把它整理成:
|
||||
|
||||
```text
|
||||
帧数 x 220字节
|
||||
```
|
||||
|
||||
也就是一个二维数组,每一行代表一帧。
|
||||
|
||||
## 依赖的本地模块
|
||||
|
||||
脚本主要依赖两个本地文件:
|
||||
|
||||
```text
|
||||
Tianwen/ccsds.py
|
||||
Tianwen/tianwen1_tm.py
|
||||
```
|
||||
|
||||
`ccsds.py` 负责解析 CCSDS AOS 帧和 Space Packet。
|
||||
|
||||
`tianwen1_tm.py` 负责把天问一号的原始时间戳转换成 `numpy.datetime64`,这样 matplotlib 才能按时间画图。
|
||||
|
||||
## 为什么要设置 matplotlib 的 Agg 后端
|
||||
|
||||
WSL 下直接调用 `plt.show()` 经常会因为没有图形界面而报错。
|
||||
|
||||
所以脚本使用:
|
||||
|
||||
```python
|
||||
matplotlib.use("Agg")
|
||||
```
|
||||
|
||||
意思是:不要弹出图形窗口,只把图保存成 PNG 文件。
|
||||
|
||||
所有图片最后会保存到:
|
||||
|
||||
```text
|
||||
/home/zjz/CCSDS_study/Tianwen/frame_analysis_figures
|
||||
```
|
||||
|
||||
## 脚本整体流程
|
||||
|
||||
脚本主要分成以下几步:
|
||||
|
||||
1. 设置路径和 matplotlib 环境。
|
||||
2. 读取 `tianwen1_frames_20200730.u8`。
|
||||
3. 把原始字节按 220 字节切成 AOS 帧。
|
||||
4. 用 `ccsds.AOSFrame.parse(...)` 解析每一帧。
|
||||
5. 统计 AOS 主头中的版本号、航天器 ID、虚拟信道 ID。
|
||||
6. 分别分析 `Spacecraft 82 / VC 1`、`Spacecraft 245 / VC 3`、`Spacecraft 245 / VC 1`。
|
||||
7. 从 AOS 帧中重组 Space Packet。
|
||||
8. 按 APID 把 Space Packet 分类。
|
||||
9. 对多个 APID 的载荷字段按 `uint8`、`int16` 或 `float64` 解释。
|
||||
10. 绘制时间序列图、帧丢失图、APID 热力图。
|
||||
11. 用 `savefig(...)` 保存所有图片。
|
||||
|
||||
## 关键概念解释
|
||||
|
||||
### AOS Frame
|
||||
|
||||
AOS Frame 可以理解为“航天器遥测传输的一帧”。每一帧里有头部字段,也有承载的数据。
|
||||
|
||||
本脚本中每帧固定 220 字节。
|
||||
|
||||
### Spacecraft ID
|
||||
|
||||
`spacecraft_id` 用来区分数据属于哪个航天器或数据源。
|
||||
|
||||
脚本主要关注:
|
||||
|
||||
```text
|
||||
82
|
||||
245
|
||||
```
|
||||
|
||||
### Virtual Channel
|
||||
|
||||
`virtual_channel_id` 是虚拟信道编号。同一个航天器 ID 下可以有多个虚拟信道。
|
||||
|
||||
脚本重点分析:
|
||||
|
||||
```text
|
||||
Spacecraft 82, Virtual channel 1
|
||||
Spacecraft 245, Virtual channel 3
|
||||
Spacecraft 245, Virtual channel 1
|
||||
```
|
||||
|
||||
其中 `Spacecraft 245 / VC 1` 是后面 APID 分析的主要数据来源。
|
||||
|
||||
### Space Packet
|
||||
|
||||
AOS 帧中承载的是 Space Packet 数据流。一个 Space Packet 可能跨越多个 AOS 帧,所以脚本调用:
|
||||
|
||||
```python
|
||||
ccsds.extract_space_packets(...)
|
||||
```
|
||||
|
||||
把它们重新拼接出来。
|
||||
|
||||
### APID
|
||||
|
||||
APID 是 Space Packet 里的应用标识。新手可以先把它理解成“数据类型编号”。
|
||||
|
||||
不同 APID 里的载荷含义可能不同,所以脚本会按 APID 分组分析。
|
||||
|
||||
## 图片保存逻辑
|
||||
|
||||
脚本新增了 `save_all_figures(output_dir)` 函数。
|
||||
|
||||
它会做这些事:
|
||||
|
||||
1. 找出当前 matplotlib 里所有还打开的 figure。
|
||||
2. 从每张图的标题中生成文件名。
|
||||
3. 调用:
|
||||
|
||||
```python
|
||||
fig.savefig(path, dpi=150, bbox_inches="tight")
|
||||
```
|
||||
|
||||
4. 保存成 PNG。
|
||||
5. 保存后关闭 figure,释放内存。
|
||||
|
||||
文件名类似:
|
||||
|
||||
```text
|
||||
figure_001_spacecraft_82_virtual_channel_1_frame_loss.png
|
||||
figure_002_apid_1_spacecraft_82_virtual_channel_1.png
|
||||
```
|
||||
|
||||
## 各分析段落在做什么
|
||||
|
||||
### Spacecraft 82 Virtual channel 1
|
||||
|
||||
这一段会:
|
||||
|
||||
- 筛选出 SC 82 / VC 1 的 AOS 帧。
|
||||
- 检查帧计数是否连续。
|
||||
- 打印插入区时间戳。
|
||||
- 重组 Space Packet。
|
||||
- 按 APID 分类。
|
||||
- 为每个 APID 画载荷热力图。
|
||||
|
||||
### Spacecraft 245 Virtual channel 3
|
||||
|
||||
这一段主要检查 VC 3 是否像固定填充数据。
|
||||
|
||||
它会查看某些字节区间的唯一值,比如是否一直是 `0xaa`。
|
||||
|
||||
### Spacecraft 245 Virtual channel 1
|
||||
|
||||
这是主要遥测数据通道。
|
||||
|
||||
这一段会:
|
||||
|
||||
- 检查帧计数。
|
||||
- 提取时间戳。
|
||||
- 重组 Space Packet。
|
||||
- 统计所有 APID。
|
||||
- 按 APID 分组并画热力图。
|
||||
|
||||
### APID 1280 / 1281
|
||||
|
||||
这些 APID 的载荷被按大端 `int16` 解释。
|
||||
|
||||
也就是说,每两个字节被看成一个 16 位整数,然后画出这些整数随时间的变化。
|
||||
|
||||
### APID 1288 / 1287
|
||||
|
||||
这些 APID 的部分载荷被按大端 `float64` 解释。
|
||||
|
||||
脚本还对这些浮点序列做了 1 阶和 2 阶多项式拟合,并画出“原始值减拟合值”的残差。
|
||||
|
||||
新手可以理解为:
|
||||
|
||||
> 先把整体趋势去掉,再看剩下的小变化。
|
||||
|
||||
### APID 2 / 3
|
||||
|
||||
原 notebook 这里变量名写了 APID 2 和 APID 3,但代码实际两边都取了 APID 2。
|
||||
|
||||
脚本保留了原逻辑,并在代码注释里特别说明了这一点。
|
||||
|
||||
### APID 454 / 462 / 1536
|
||||
|
||||
这些段落分别按 `uint8` 或 `int16` 查看指定字节位置的变化。
|
||||
|
||||
目的不是完全解码每个遥测字段,而是先通过画图观察哪些字节像计数器、哪些字节像连续变化的遥测量。
|
||||
|
||||
## 运行方式
|
||||
|
||||
在项目根目录下可以运行:
|
||||
|
||||
```bash
|
||||
/home/zjz/CCSDS_study/ccsds/bin/python /home/zjz/CCSDS_study/test/Tianwen-1-frame-analysis-v2.py
|
||||
```
|
||||
|
||||
运行结束后,查看图片目录:
|
||||
|
||||
```text
|
||||
/home/zjz/CCSDS_study/Tianwen/frame_analysis_figures
|
||||
```
|
||||
|
||||
## 常见提示
|
||||
|
||||
运行时可能看到类似:
|
||||
|
||||
```text
|
||||
Broken stream. Last frame count ..., current frame count ...
|
||||
```
|
||||
|
||||
这是 `ccsds.py` 发出的 warning,意思是帧计数中间不连续,可能有丢帧或数据中断。
|
||||
|
||||
这不是 Python 程序崩溃,也不影响脚本继续保存图片。
|
||||
|
||||
## 新手视角总结
|
||||
|
||||
这个脚本不是一个“完整解码所有遥测含义”的程序,而是一个“探索和观察遥测数据结构”的分析脚本。
|
||||
|
||||
它先把原始二进制文件拆成 AOS 帧,再把 AOS 帧里的 Space Packet 拼出来,然后按照 APID 分组。分组之后,它把某些字节当成整数或浮点数画出来,让我们通过图片观察这些字段是否有规律。
|
||||
|
||||
简单说:
|
||||
|
||||
```text
|
||||
原始二进制数据
|
||||
-> AOS 帧
|
||||
-> Space Packet
|
||||
-> APID 分组
|
||||
-> 字节/整数/浮点数曲线
|
||||
-> PNG 图片
|
||||
```
|
||||
|
||||
如果你是新手,建议先从这几个变量看起:
|
||||
|
||||
- `frames`:原始帧字节数组。
|
||||
- `aos`:解析后的 AOS 帧对象。
|
||||
- `sc245_vc1`:筛选出的主要遥测虚拟信道。
|
||||
- `sc245_vc1_packets`:从 AOS 帧中重组出来的 Space Packet。
|
||||
- `sc245_vc1_by_apid`:按 APID 分类后的 packet 字典。
|
||||
- `channels`:某个 APID 的载荷被解释成数值后的矩阵。
|
||||
- `timestamps`:每个 packet 对应的时间戳。
|
||||
|
||||
理解这些变量后,整个脚本的结构就比较清楚了。
|
||||
867
test/Tianwen-1-frame-analysis-v2.py
Normal file
@@ -0,0 +1,867 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tianwen-1 frame analysis notebook converted to a Python script.
|
||||
|
||||
本脚本由 `Tianwen-1 frame analysis.ipynb` 转换而来,保留原 notebook
|
||||
的执行顺序,并把原 Markdown 标题和交互式观察步骤整理为注释。
|
||||
|
||||
脚本主要功能:
|
||||
1. 读取 2020-07-30 的天问一号 AOS 帧二进制数据。
|
||||
2. 使用本目录下的 `ccsds.py` 解析 AOS 主头、插入区和 Space Packet。
|
||||
3. 按 spacecraft ID、virtual channel 和 APID 对数据分组。
|
||||
4. 绘制帧计数、时间戳、载荷字节和若干遥测通道的变化曲线。
|
||||
|
||||
注意:
|
||||
- 原 notebook 假设当前工作目录就是 `Tianwen/`。本脚本会自动切换到
|
||||
文件所在目录,以便相对路径 `tianwen1_frames_20200730.u8` 能正常工作。
|
||||
- notebook 中单独显示表达式结果的单元,在脚本中改为 `print(...)`。
|
||||
- 绘图默认使用 matplotlib 当前后端;如果在无图形界面环境运行,可在
|
||||
执行前设置 `MPLBACKEND=Agg`。
|
||||
"""
|
||||
|
||||
|
||||
# sys 用来修改 Python 的模块搜索路径。脚本在 test/ 目录下,
|
||||
# 但要导入 Tianwen/ 目录里的模块,所以需要把项目根目录加进去。
|
||||
import sys
|
||||
|
||||
# pathlib.Path 是处理文件路径的标准库工具。这里取别名 PathlibPath,
|
||||
# 是为了避免后面 `from construct import *` 导入的 Path 把它覆盖掉。
|
||||
from pathlib import Path as PathlibPath
|
||||
|
||||
# os 用来切换当前工作目录、设置环境变量。
|
||||
import os
|
||||
|
||||
# re 是正则表达式库,用来把图标题清洗成安全的文件名。
|
||||
import re
|
||||
|
||||
# 获取项目根目录。当前文件在 test/ 下,所以 parent.parent 就是 CCSDS_study/。
|
||||
PROJECT_ROOT = PathlibPath(__file__).resolve().parent.parent
|
||||
|
||||
# 把项目根目录加入 sys.path 后,Python 才能通过 `import Tianwen.xxx`
|
||||
# 找到 /home/zjz/CCSDS_study/Tianwen 这个包目录。
|
||||
sys.path.append(str(PROJECT_ROOT))
|
||||
|
||||
# collections.Counter 可以方便地统计某个字段不同取值出现了多少次。
|
||||
import collections
|
||||
|
||||
# struct 用来按指定字节序把 bytes 解析成整数;这里用于解析 packet 内部时间戳。
|
||||
import struct
|
||||
|
||||
# Matplotlib 会写缓存文件。WSL 或某些 sandbox 环境下用户配置目录可能不可写,
|
||||
# 因此把缓存目录放到 /tmp/matplotlib,避免启动时出现权限警告。
|
||||
os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
|
||||
|
||||
# 先导入 matplotlib 主模块,设置后端后再导入 pyplot。
|
||||
import matplotlib
|
||||
|
||||
# Agg 是非交互式绘图后端,只负责把图片画到文件中,不需要 GUI 窗口。
|
||||
# 这对 WSL 很重要,因为 WSL 下直接 plt.show() 经常会因为没有显示服务而报错。
|
||||
matplotlib.use("Agg")
|
||||
|
||||
# pyplot 是 matplotlib 最常用的画图接口,习惯上命名为 plt。
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# 脚本会创建很多张图。这个参数关闭“打开超过 20 个 figure”的提示,
|
||||
# 因为这里的大量 figure 是预期行为,最后会统一保存并关闭。
|
||||
plt.rcParams["figure.max_open_warning"] = 0
|
||||
|
||||
# numpy 用于高效读取二进制文件、重塑数组、计算差分和处理时间序列。
|
||||
import numpy as np
|
||||
|
||||
# scipy.signal 是 notebook 原始导入;当前脚本没有直接使用,保留它是为了尽量
|
||||
# 与原 notebook 环境一致,后续如果扩展信号处理步骤可直接使用。
|
||||
import scipy.signal # 保留 notebook 原始导入;当前脚本未显式调用。
|
||||
|
||||
# construct 是解析二进制协议结构的库。ccsds.py 中的帧结构定义依赖它。
|
||||
# 这里使用星号导入是 notebook 原始写法;新项目中通常更建议显式导入需要的名字。
|
||||
from construct import * # noqa: F401,F403 - ccsds 解析结构依赖 construct 风格对象。
|
||||
|
||||
# ccsds.py 定义了 AOSFrame、SpacePacketPrimaryHeader 和 Space Packet 重组逻辑。
|
||||
import Tianwen.ccsds as ccsds
|
||||
|
||||
# tianwen1_tm.py 定义了天问一号时间戳到 numpy.datetime64 的转换函数。
|
||||
import Tianwen.tianwen1_tm as tianwen1_tm
|
||||
|
||||
|
||||
# `construct import *` 会导入名为 Path 的对象,因此 pathlib.Path 必须使用别名。
|
||||
# notebook 使用相对路径读取 Tianwen 目录下的数据文件;脚本位于 test/ 目录时,
|
||||
# 运行前需要切换到真正的数据目录。
|
||||
DATA_DIR = PROJECT_ROOT / "Tianwen"
|
||||
|
||||
# 所有 PNG 图片统一保存到这个目录,避免和源码文件混在一起。
|
||||
OUTPUT_DIR = DATA_DIR / "frame_analysis_figures"
|
||||
|
||||
|
||||
def safe_filename(text, max_length=120):
|
||||
"""把图标题转换成适合作为文件名的短字符串。"""
|
||||
|
||||
# 去掉标题首尾空白,并统一转成小写,让文件名风格一致。
|
||||
text = text.strip().lower()
|
||||
|
||||
# 把不适合放进文件名的字符替换为下划线。
|
||||
text = re.sub(r"[^0-9a-zA-Z._() -]+", "_", text)
|
||||
|
||||
# 把连续空白压缩成一个下划线。
|
||||
text = re.sub(r"\s+", "_", text)
|
||||
|
||||
# 去掉文件名开头/结尾的点、下划线、短横线和空格。
|
||||
text = text.strip("._- ")
|
||||
|
||||
# 如果标题为空,就用 untitled;再截断到最大长度,避免文件名过长。
|
||||
return (text or "untitled")[:max_length]
|
||||
|
||||
|
||||
def save_all_figures(output_dir):
|
||||
"""保存当前 matplotlib 会话中的所有 figure。
|
||||
|
||||
文件名格式为 `figure_序号_标题.png`。标题来自当前坐标轴的 title;
|
||||
如果没有标题,则使用 `untitled`。保存后关闭 figure,降低长脚本的内存占用。
|
||||
"""
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# saved_paths 用来记录保存出的每一个 PNG 路径,方便最后返回或调试。
|
||||
saved_paths = []
|
||||
|
||||
# plt.get_fignums() 返回当前还打开着的所有 figure 编号。
|
||||
for figure_number in plt.get_fignums():
|
||||
# 根据编号取回 figure 对象。
|
||||
fig = plt.figure(figure_number)
|
||||
|
||||
# 一个 figure 里可以有一个或多个坐标轴 axes。
|
||||
axes = fig.get_axes()
|
||||
|
||||
# 默认标题为空;后面会尝试从坐标轴里找第一个非空标题。
|
||||
title = ""
|
||||
|
||||
# 遍历坐标轴,拿到用于生成文件名的图标题。
|
||||
for ax in axes:
|
||||
title = ax.get_title().strip()
|
||||
if title:
|
||||
break
|
||||
|
||||
# 文件名前缀使用 figure 序号,保证即使标题重复也不会互相覆盖。
|
||||
filename = f"figure_{figure_number:03d}_{safe_filename(title)}.png"
|
||||
|
||||
# 拼出完整保存路径。
|
||||
path = output_dir / filename
|
||||
|
||||
# 真正保存图片。dpi 控制分辨率,bbox_inches='tight' 尽量裁掉多余白边。
|
||||
fig.savefig(path, dpi=150, bbox_inches="tight")
|
||||
|
||||
# 记录保存路径。
|
||||
saved_paths.append(path)
|
||||
|
||||
# 保存后关闭 figure,释放内存。
|
||||
plt.close(fig)
|
||||
|
||||
# 打印保存数量和目录,方便运行脚本后立刻知道去哪里看图片。
|
||||
print(f"Saved {len(saved_paths)} figures to {output_dir}")
|
||||
|
||||
# 返回路径列表,便于以后如果把 main 拆成函数时继续使用。
|
||||
return saved_paths
|
||||
|
||||
|
||||
def get_packet(p):
|
||||
"""返回 Space Packet 原始字节。
|
||||
|
||||
`ccsds.extract_space_packets(..., get_timestamps=True)` 返回
|
||||
`(packet_bytes, timestamp)` 二元组;不带时间戳时只返回 packet bytes。
|
||||
这个辅助函数屏蔽两种返回格式差异。
|
||||
"""
|
||||
|
||||
# 如果 p 是 tuple,说明它是 `(packet_bytes, timestamp)`;
|
||||
# 如果不是 tuple,说明它本身就是 packet bytes。
|
||||
return p[0] if type(p) is tuple else p
|
||||
|
||||
|
||||
def packets_asarray(packets):
|
||||
"""把 Space Packet 载荷转换为二维 uint8 数组。
|
||||
|
||||
每个 Space Packet 的前部是 CCSDS Space Packet Primary Header。
|
||||
本函数跳过主头,只保留后续用户数据区,便于直接按字节或按数值类型
|
||||
重解释为遥测通道矩阵。
|
||||
"""
|
||||
|
||||
# 最终返回一个二维数组:行是 packet,列是 packet 载荷中的字节位置。
|
||||
return np.array(
|
||||
[
|
||||
np.frombuffer(
|
||||
# get_packet(p) 先拿到原始 packet bytes;
|
||||
# 再跳过 Space Packet Primary Header,只保留用户数据区。
|
||||
get_packet(p)[ccsds.SpacePacketPrimaryHeader.sizeof() :],
|
||||
"uint8",
|
||||
)
|
||||
for p in packets
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def plot_apids(apids, sc, vc):
|
||||
"""按 APID 绘制 Space Packet 载荷热力图。
|
||||
|
||||
横轴对应载荷字节位置,纵轴对应同一 APID 下的 packet 序号。这个图常用于
|
||||
观察哪些字段固定、哪些字段随时间变化,以及是否存在明显的分段结构。
|
||||
"""
|
||||
|
||||
for apid in sorted(apids.keys()):
|
||||
# 每个 APID 单独生成一张图。
|
||||
plt.figure(figsize=(16, 16), facecolor="w")
|
||||
|
||||
# 把这个 APID 下所有 packet 载荷堆叠成二维数组。
|
||||
ps = packets_asarray(apids[apid])
|
||||
|
||||
# imshow 把二维字节数组画成热力图,颜色代表字节值。
|
||||
plt.imshow(ps, aspect=ps.shape[1] / ps.shape[0])
|
||||
|
||||
# 标题写清楚 APID、航天器 ID 和虚拟信道 ID,保存成文件后也容易辨认。
|
||||
plt.title(f"APID {apid} Spacecraft {sc} Virtual channel {vc}")
|
||||
|
||||
|
||||
def get_timestamps(aos):
|
||||
"""从 AOS 帧插入区提取时间戳,并转换为 numpy.datetime64。"""
|
||||
|
||||
# a.insert_zone.timestamp 是 AOS 帧插入区中的原始时间戳字段。
|
||||
return np.array(
|
||||
[tianwen1_tm.parse_timestamp_datetime64(a.insert_zone.timestamp) for a in aos]
|
||||
)
|
||||
|
||||
|
||||
def get_packet_timestamps(packets):
|
||||
"""从 `(packet, timestamp)` 形式的 Space Packet 列表提取时间戳。"""
|
||||
|
||||
# packets 中的 p[1] 是 extract_space_packets(..., get_timestamps=True)
|
||||
# 附带返回的时间戳。
|
||||
return np.array([tianwen1_tm.parse_timestamp_datetime64(p[1]) for p in packets])
|
||||
|
||||
|
||||
def main():
|
||||
"""按原 notebook 顺序执行完整分析流程。"""
|
||||
|
||||
# 切换到 Tianwen/ 数据目录后,np.fromfile("tianwen1_frames_20200730.u8")
|
||||
# 这种相对路径才能正确找到数据文件。
|
||||
os.chdir(DATA_DIR)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 读取原始 AOS 帧数据
|
||||
# -------------------------------------------------------------------------
|
||||
# 数据文件由连续的 220 字节帧组成。若文件末尾存在不足一帧的残留字节,
|
||||
# 先截断到完整帧边界,再 reshape 成 `帧数 x 220` 的二维数组。
|
||||
# 每个 AOS 帧固定 220 字节,这是后面 reshape 的依据。
|
||||
frame_size = 220
|
||||
|
||||
# 从二进制文件中一次性读取所有字节,dtype='uint8' 表示每个元素是 0-255 的字节。
|
||||
frames = np.fromfile("tianwen1_frames_20200730.u8", dtype="uint8")
|
||||
|
||||
# frames.size // frame_size 得到完整帧数量。
|
||||
# 乘回 frame_size 后进行切片,可以丢弃文件末尾不满 220 字节的残余数据。
|
||||
# reshape((-1, frame_size)) 把一维字节流变成二维表:每一行是一帧。
|
||||
frames = frames[: frames.size // frame_size * frame_size].reshape((-1, frame_size))
|
||||
|
||||
# 打印总帧数,方便确认数据是否读取完整。
|
||||
print("Number of frames:", frames.shape[0])
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# AOS headers
|
||||
# -------------------------------------------------------------------------
|
||||
# 解析所有 AOS 帧,得到 primary header、insert zone 等结构化字段。
|
||||
# f 是一行 220 字节的 numpy 数组;ccsds.AOSFrame.parse 会按 CCSDS AOS
|
||||
# 帧格式把它解释为带字段名的 Container。
|
||||
aos = [ccsds.AOSFrame.parse(f) for f in frames]
|
||||
|
||||
# 统计 AOS transfer frame version number。主流值应集中在有效版本上,
|
||||
# 零散异常值通常来自错误同步、误帧或数据噪声。
|
||||
print(
|
||||
"Transfer frame versions:",
|
||||
# Counter 会返回类似 Counter({1: 111790, 2: 147}) 的统计结果。
|
||||
collections.Counter([a.primary_header.transfer_frame_version_number for a in aos]),
|
||||
)
|
||||
|
||||
# 统计 spacecraft ID,确认捕获数据中主要包含哪些航天器标识。
|
||||
print(
|
||||
"Spacecraft IDs:",
|
||||
# spacecraft_id 用来区分不同航天器或不同数据源。
|
||||
collections.Counter([a.primary_header.spacecraft_id for a in aos]),
|
||||
)
|
||||
|
||||
# 分别观察 spacecraft 245 和 82 下出现的 virtual channel。
|
||||
print(
|
||||
"SC 245 virtual channels:",
|
||||
collections.Counter(
|
||||
[
|
||||
# virtual_channel_id 是同一 spacecraft 下的数据通道编号。
|
||||
a.primary_header.virtual_channel_id
|
||||
for a in aos
|
||||
# 这里只统计 spacecraft_id 为 245 的帧。
|
||||
if a.primary_header.spacecraft_id == 245
|
||||
]
|
||||
),
|
||||
)
|
||||
print(
|
||||
"SC 82 virtual channels:",
|
||||
collections.Counter(
|
||||
[
|
||||
a.primary_header.virtual_channel_id
|
||||
for a in aos
|
||||
# 这里只统计 spacecraft_id 为 82 的帧。
|
||||
if a.primary_header.spacecraft_id == 82
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
# 打印前 20 帧的 `(spacecraft_id, virtual_channel_id)` 序列,观察复用模式。
|
||||
print(
|
||||
"First 20 SC/VC pairs:",
|
||||
[
|
||||
# 每个 tuple 展示一帧的 spacecraft 和 virtual channel。
|
||||
(a.primary_header.spacecraft_id, a.primary_header.virtual_channel_id)
|
||||
for a in aos[:20]
|
||||
],
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Spacecraft 82 Virtual channel 1
|
||||
# -------------------------------------------------------------------------
|
||||
# 取出 SC 82 / VC 1 的帧,检查帧计数连续性和插入区字段。
|
||||
print(
|
||||
"SC 82 VC 1 first headers:",
|
||||
# 先打印前 10 个 header,人工检查字段是否看起来合理。
|
||||
[a.primary_header for a in aos if a.primary_header.spacecraft_id == 82][:10],
|
||||
)
|
||||
|
||||
# 过滤出 spacecraft_id=82 且 virtual_channel_id=1 的所有 AOS 帧。
|
||||
sc82_vc1 = [
|
||||
a
|
||||
for a in aos
|
||||
if a.primary_header.spacecraft_id == 82
|
||||
and a.primary_header.virtual_channel_id == 1
|
||||
]
|
||||
|
||||
# 提取虚拟信道帧计数。正常情况下相邻帧计数应该连续递增。
|
||||
fc = np.array([a.primary_header.virtual_channel_frame_count for a in sc82_vc1])
|
||||
|
||||
# 新建一张图,用来画帧丢失情况。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
|
||||
# np.diff(fc) 是相邻帧计数差;如果连续递增,差值应为 1。
|
||||
# 减 1 后,0 表示未丢帧,正数表示中间可能缺了多少帧。
|
||||
plt.plot(np.diff(fc) - 1)
|
||||
|
||||
# 图标题和坐标轴标签会显示在图片上,也会参与生成保存文件名。
|
||||
plt.title("Spacecraft 82 Virtual channel 1 frame loss")
|
||||
plt.ylabel("Lost frames")
|
||||
plt.xlabel("Frame number")
|
||||
|
||||
print(
|
||||
"SC 82 VC 1 first insert-zone timestamps:",
|
||||
# hex(...) 把整数时间戳打印成十六进制,方便和二进制协议字段对照。
|
||||
[hex(a.insert_zone.timestamp) for a in sc82_vc1][:10],
|
||||
)
|
||||
print(
|
||||
"SC 82 VC 1 insert-zone unknown values:",
|
||||
# set 推导式会去重,用来查看 unknown 字段是否固定。
|
||||
{hex(a.insert_zone.unknown) for a in sc82_vc1},
|
||||
)
|
||||
|
||||
# 从该虚拟信道重组 Space Packet,并按 APID 分组。
|
||||
# AOS 帧里承载的是 Space Packet 数据流;extract_space_packets 会把跨帧的
|
||||
# Space Packet 重新拼出来。
|
||||
sc82_vc1_packets = list(ccsds.extract_space_packets(sc82_vc1, 82, 1))
|
||||
|
||||
# 每个 Space Packet 开头都有 Primary Header,这里解析这些 header。
|
||||
sc82_vc1_sp_headers = [
|
||||
ccsds.SpacePacketPrimaryHeader.parse(p) for p in sc82_vc1_packets
|
||||
]
|
||||
|
||||
# APID 是 Space Packet 的应用进程标识。统计 APID 可以知道有哪些数据类型。
|
||||
sc82_vc1_apids = collections.Counter([p.APID for p in sc82_vc1_sp_headers])
|
||||
print("SC 82 VC 1 APIDs:", sc82_vc1_apids)
|
||||
|
||||
# 构造字典:key 是 APID,value 是属于该 APID 的 packet 列表。
|
||||
sc82_vc1_by_apid = {
|
||||
apid: [
|
||||
p
|
||||
# zip 把 header 和 packet 一一配对,便于按 header.APID 筛选 packet。
|
||||
for h, p in zip(sc82_vc1_sp_headers, sc82_vc1_packets)
|
||||
if h.APID == apid
|
||||
]
|
||||
for apid in sc82_vc1_apids
|
||||
}
|
||||
|
||||
# 为每个 APID 保存一张载荷热力图。
|
||||
plot_apids(sc82_vc1_by_apid, 82, 1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Spacecraft 245 Virtual channel 3
|
||||
# -------------------------------------------------------------------------
|
||||
# SC 245 / VC 3 似乎包含固定或填充类数据。这里检查帧计数和若干字节区间。
|
||||
# 过滤出 spacecraft_id=245 且 virtual_channel_id=3 的帧。
|
||||
sc245_vc3 = [
|
||||
a
|
||||
for a in aos
|
||||
if a.primary_header.spacecraft_id == 245
|
||||
and a.primary_header.virtual_channel_id == 3
|
||||
]
|
||||
|
||||
# 提取 VC 3 的帧计数,用于检查是否连续。
|
||||
fc = np.array([a.primary_header.virtual_channel_frame_count for a in sc245_vc3])
|
||||
|
||||
# 绘制 VC 3 的疑似丢帧情况。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(np.diff(fc) - 1)
|
||||
plt.title("Spacecraft 245 Virtual channel 3 frame loss")
|
||||
plt.ylabel("Lost frames")
|
||||
plt.xlabel("Frame number")
|
||||
|
||||
print("SC 245 VC 3 first headers:", [a.primary_header for a in sc245_vc3[:10]])
|
||||
|
||||
# 这里需要原始字节帧,而不是解析后的 aos Container,所以从 frames 和 aos
|
||||
# 同时遍历,筛选出 SC 245 / VC 3 对应的原始 220 字节帧。
|
||||
sc245_vc3_frames = np.array(
|
||||
[
|
||||
f
|
||||
for f, a in zip(frames, aos)
|
||||
if a.primary_header.spacecraft_id == 245
|
||||
and a.primary_header.virtual_channel_id == 3
|
||||
]
|
||||
)
|
||||
|
||||
# np.unique 查看某些字节范围里出现过哪些不同值。
|
||||
# 如果只出现一个值,说明该字段可能是固定填充。
|
||||
print("SC 245 VC 3 unique bytes 5-12:", np.unique(sc245_vc3_frames[:, 5:12]))
|
||||
print("hex(170):", hex(170))
|
||||
print("SC 245 VC 3 unique byte 12:", np.unique(sc245_vc3_frames[:, 12]))
|
||||
print("hex(138):", hex(138))
|
||||
print("SC 245 VC 3 unique bytes 13-end:", np.unique(sc245_vc3_frames[:, 13:]))
|
||||
print("SC 245 VC 3 insert-zone unknown:", hex(sc245_vc3[0].insert_zone.unknown))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Spacecraft 245 Virtual channel 1
|
||||
# -------------------------------------------------------------------------
|
||||
# SC 245 / VC 1 是后续 APID 分析的主要数据源。
|
||||
# 过滤出主要遥测通道 SC 245 / VC 1。
|
||||
sc245_vc1 = [
|
||||
a
|
||||
for a in aos
|
||||
if a.primary_header.spacecraft_id == 245
|
||||
and a.primary_header.virtual_channel_id == 1
|
||||
]
|
||||
|
||||
# 提取该虚拟信道的帧计数。
|
||||
fc = np.array([a.primary_header.virtual_channel_frame_count for a in sc245_vc1])
|
||||
|
||||
# 绘制该通道的帧丢失情况。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(np.diff(fc) - 1)
|
||||
plt.title("Spacecraft 245 Virtual channel 1 frame loss")
|
||||
plt.ylabel("Lost frames")
|
||||
plt.xlabel("Frame number")
|
||||
|
||||
print("SC 245 VC 1 first headers:", [a.primary_header for a in sc245_vc1[:10]])
|
||||
|
||||
# 从 AOS 插入区中取出时间戳,后续画“帧计数随时间变化”的散点图。
|
||||
sc245_vc1_timestamps = get_timestamps(sc245_vc1)
|
||||
|
||||
# 画帧计数随时间的变化。横轴是真实时间,纵轴是 virtual channel frame count。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(sc245_vc1_timestamps, fc, ".")
|
||||
plt.title("Spacecraft 245 Virtual channel 1")
|
||||
plt.xlabel("Timestamp")
|
||||
plt.ylabel("Virtual channel frame count")
|
||||
plt.grid()
|
||||
|
||||
print(
|
||||
"SC 245 VC 1 insert-zone unknown values:",
|
||||
{hex(a.insert_zone.unknown) for a in sc245_vc1},
|
||||
)
|
||||
|
||||
# 重组 SC 245 / VC 1 中的 Space Packet,并保留 AOS 帧插入区时间戳。
|
||||
# get_timestamps=True 表示每个 packet 会附带其所在 AOS 帧的时间戳。
|
||||
sc245_vc1_packets = list(
|
||||
ccsds.extract_space_packets(sc245_vc1, 245, 1, get_timestamps=True)
|
||||
)
|
||||
|
||||
# 因为 packet 现在是 `(packet_bytes, timestamp)`,所以解析 header 时使用 p[0]。
|
||||
sc245_vc1_sp_headers = [
|
||||
ccsds.SpacePacketPrimaryHeader.parse(p[0]) for p in sc245_vc1_packets
|
||||
]
|
||||
|
||||
# 统计主遥测通道中每个 APID 出现的 packet 数量。
|
||||
sc245_vc1_apids = collections.Counter([p.APID for p in sc245_vc1_sp_headers])
|
||||
print("SC 245 VC 1 APIDs:", sc245_vc1_apids)
|
||||
|
||||
# 按 APID 对 packet 分桶,后续每个 APID 单独分析。
|
||||
sc245_vc1_by_apid = {
|
||||
apid: [
|
||||
p
|
||||
for h, p in zip(sc245_vc1_sp_headers, sc245_vc1_packets)
|
||||
if h.APID == apid
|
||||
]
|
||||
for apid in sc245_vc1_apids
|
||||
}
|
||||
|
||||
# 为 SC 245 / VC 1 的每个 APID 生成一张载荷热力图。
|
||||
plot_apids(sc245_vc1_by_apid, 245, 1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 1280
|
||||
# -------------------------------------------------------------------------
|
||||
# 将 APID 1280 的载荷按大端 int16 解释,观察前 12 字节的遥测量。
|
||||
# packets_asarray(...) 得到 uint8 字节矩阵;view("int16") 把每两个字节看成
|
||||
# 一个 16 位整数;byteswap() 用来处理大端/小端字节序差异。
|
||||
channels = packets_asarray(sc245_vc1_by_apid[1280]).view("int16").byteswap()
|
||||
|
||||
# 取出每个 packet 对应的 AOS 帧时间戳,作为绘图横轴。
|
||||
timestamps = get_packet_timestamps(sc245_vc1_by_apid[1280])
|
||||
|
||||
# 绘制 APID 1280 前 6 个 int16 通道,也就是原始载荷 bytes 0-12。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps, channels[:, :6])
|
||||
plt.title("APID 1280 Spacecraft 245 Virtual channel 1 bytes 0-12 (int16)")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (int16)")
|
||||
plt.legend([f"Bytes {j} and {j + 1}" for j in range(0, 12, 2)])
|
||||
|
||||
# 第二张图只看局部区间,便于放大观察某段时间内的细节变化。
|
||||
plt.figure(figsize=(14, 6), facecolor="w")
|
||||
|
||||
# slice(850, 1200) 表示取第 850 到第 1199 个样本。
|
||||
sel = slice(850, 1200)
|
||||
plt.plot(timestamps[sel], channels[sel, :6])
|
||||
plt.title("APID 1280 Spacecraft 245 Virtual channel 1 bytes 0-12 (int16)")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (int16)")
|
||||
plt.legend([f"Bytes {j} and {j + 1}" for j in range(0, 12, 2)])
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 1281
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 1281 同样按大端 int16 解释,分段查看 18-36 字节。
|
||||
# channels 的行是 packet 序号,列是 int16 通道序号。
|
||||
channels = packets_asarray(sc245_vc1_by_apid[1281]).view("int16").byteswap()
|
||||
|
||||
# timestamps 和 channels 的行一一对应。
|
||||
timestamps = get_packet_timestamps(sc245_vc1_by_apid[1281])
|
||||
|
||||
# 查看 int16 通道 9 到 14,对应字节范围 18-30。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps, channels[:, 9:15])
|
||||
plt.title("APID 1281 Spacecraft 245 Virtual channel 1 bytes 18-30 (int16)")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (int16)")
|
||||
plt.legend([f"Bytes {j} and {j + 1}" for j in range(9 * 2, 15 * 2, 2)])
|
||||
|
||||
# 放大 APID 1281 的一小段样本。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
sel = slice(400, 600)
|
||||
plt.plot(timestamps[sel], channels[sel, 9:15])
|
||||
plt.title("APID 1281 Spacecraft 245 Virtual channel 1 bytes 18-30 (int16)")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (int16)")
|
||||
plt.legend([f"Bytes {j} and {j + 1}" for j in range(9 * 2, 15 * 2, 2)])
|
||||
|
||||
# 查看 int16 通道 15 到 17,对应字节范围 30-36。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps, channels[:, 15:18])
|
||||
plt.title("APID 1281 Spacecraft 245 Virtual channel 1 bytes 30-36 (int16)")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (int16)")
|
||||
plt.legend([f"Bytes {j} and {j + 1}" for j in range(2 * 15, 18 * 2, 2)])
|
||||
|
||||
# 同样对 bytes 30-36 的局部区间放大观察。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps[sel], channels[sel, 15:18])
|
||||
plt.title("APID 1281 Spacecraft 245 Virtual channel 1 bytes 30-36 (int16)")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (int16)")
|
||||
plt.legend([f"Bytes {j} and {j + 1}" for j in range(2 * 15, 18 * 2, 2)])
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 1288
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 1288 的载荷偏移 80 后,前 24 字节按三个大端 float64 解释。
|
||||
# notebook 中把最大时间间隔对应行置为 NaN,用于清理明显观测间断。
|
||||
# [:, 80:] 表示丢掉载荷前 80 字节;[:, : 3 * 8] 表示再取 24 字节。
|
||||
# 24 字节正好可以看成 3 个 float64。
|
||||
channels = (
|
||||
np.copy(packets_asarray(sc245_vc1_by_apid[1288])[:, 80:][:, : 3 * 8])
|
||||
.view("float64")
|
||||
.byteswap()
|
||||
)
|
||||
|
||||
# APID 1288 的每个 packet 时间戳。
|
||||
timestamps = get_packet_timestamps(sc245_vc1_by_apid[1288])
|
||||
|
||||
# np.diff(timestamps) 找相邻样本的时间间隔;argmax 找最大间隔位置。
|
||||
# 最大间隔可能代表观测中断,因此把该点设成 NaN,绘图时会断开。
|
||||
channels[np.argmax(np.diff(timestamps)), :] = np.nan
|
||||
|
||||
# 绘制三个 float64 通道的原始变化。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps, channels)
|
||||
plt.title("APID 1288 Spacecraft 245 Virtual channel 1 bytes 80-104 (float64)")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (float64)")
|
||||
plt.legend([f"Bytes {j}-{j + 8}" for j in range(80, 104, 8)])
|
||||
plt.grid()
|
||||
|
||||
# 对每个 float64 通道拟合 1 阶和 2 阶多项式,并绘制去趋势残差。
|
||||
# 去趋势的目的:把总体平滑变化减掉,只看剩余的小幅波动。
|
||||
for deg in [1, 2]:
|
||||
# 每个多项式阶数单独画一张图。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
|
||||
# channels.shape[1] 是通道数量,这里通常是 3。
|
||||
for j in range(channels.shape[1]):
|
||||
# 把 datetime64 转成“相对起点的秒数”,polyfit 需要普通数值横轴。
|
||||
s = (timestamps - timestamps[0]) / np.timedelta64(1, "s")
|
||||
|
||||
# 过滤 NaN 点,否则 polyfit 无法拟合。
|
||||
sel = ~np.isnan(channels[:, j])
|
||||
|
||||
# np.polyfit 拟合 deg 阶多项式,返回多项式系数。
|
||||
p = np.polyfit(s[sel], channels[sel, j], deg)
|
||||
|
||||
# np.polyval 计算拟合曲线;原始值减拟合曲线就是残差。
|
||||
plt.plot(timestamps, channels[:, j] - np.polyval(p, s))
|
||||
plt.title("APID 1288 Spacecraft 245 Virtual channel 1 bytes 80-104 (float64)")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (float64)")
|
||||
plt.legend(
|
||||
[
|
||||
f"Bytes {j}-{j + 8} minus degree {deg} polynomial"
|
||||
for j in range(80, 104, 8)
|
||||
]
|
||||
)
|
||||
plt.grid()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 1287
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 1287 的载荷偏移 66 后,前 24 字节按三个大端 float64 解释。
|
||||
# 另外从 packet 内部字节 18 起取 6 字节时间字段,补两个高位零后按大端
|
||||
# uint64 解析,与 AOS 插入区时间戳比较。
|
||||
# 这里的处理方式和 APID 1288 类似,只是 float64 起始偏移变成 66。
|
||||
channels = (
|
||||
np.copy(packets_asarray(sc245_vc1_by_apid[1287])[:, 66:][:, : 3 * 8])
|
||||
.view("float64")
|
||||
.byteswap()
|
||||
)
|
||||
|
||||
# AOS 帧插入区提供的时间戳。
|
||||
timestamps = get_packet_timestamps(sc245_vc1_by_apid[1287])
|
||||
|
||||
# Space Packet 自身载荷中也有一个 6 字节时间字段。
|
||||
timestamps_packet = np.array(
|
||||
[
|
||||
tianwen1_tm.parse_timestamp_datetime64(
|
||||
# b"\x00\x00" + 6 字节时间字段 = 8 字节,才能用 >Q 解析为 uint64。
|
||||
struct.unpack(">Q", b"\x00\x00" + p[0][18:][:6])[0]
|
||||
)
|
||||
for p in sc245_vc1_by_apid[1287]
|
||||
]
|
||||
)
|
||||
|
||||
# 清理最大时间间隔处的样本,避免图中出现不合理连接。
|
||||
channels[np.argmax(np.diff(timestamps)), :] = np.nan
|
||||
|
||||
# 比较 AOS 帧时间戳和 Space Packet 内部时间戳之间的差值。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
|
||||
# 时间戳相减得到 timedelta64,再除以 1 秒单位,得到浮点秒数。
|
||||
t_diff = (timestamps - timestamps_packet) / np.timedelta64(1, "s")
|
||||
|
||||
# 同样在最大间隔处断开曲线。
|
||||
t_diff[np.argmax(np.diff(timestamps))] = np.nan
|
||||
plt.plot(timestamps, t_diff)
|
||||
plt.title("APID 1287 Spacecraft 245 Virtual channel 1 timestamp difference")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("AOS frame timestamp - Space packet timestamp")
|
||||
|
||||
# 绘制 APID 1287 三个 float64 通道,横轴使用 packet 内部时间戳。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps_packet, channels)
|
||||
plt.title("APID 1287 Spacecraft 245 Virtual channel 1 bytes 66-90 (float64)")
|
||||
plt.xlabel("Space Packet frame timestamp")
|
||||
plt.ylabel("Raw value (float64)")
|
||||
plt.legend([f"Bytes {j}-{j + 8}" for j in range(66, 90, 8)])
|
||||
plt.grid()
|
||||
|
||||
# 分别对全时段和 2020-07-31 03:00 前的数据做 1 阶、2 阶去趋势分析。
|
||||
# cut 是满足 timestamp <= 2020-07-31T03:00 的最后一个索引。
|
||||
cut = np.where(timestamps_packet <= np.datetime64("2020-07-31T03:00"))[0][-1]
|
||||
|
||||
# 第一个 slice(None, None) 表示全时段;第二个 slice(0, cut) 表示前半段。
|
||||
for selt in [slice(None, None), slice(0, cut)]:
|
||||
for deg in [1, 2]:
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
for j in range(channels.shape[1]):
|
||||
# 使用当前选中时间段的起点作为 0 秒。
|
||||
s = (
|
||||
timestamps_packet[selt] - timestamps_packet[selt][0]
|
||||
) / np.timedelta64(1, "s")
|
||||
|
||||
# 只用非 NaN 数据点参与拟合。
|
||||
sel = ~np.isnan(channels[selt, j])
|
||||
|
||||
# 对当前通道和当前时间段拟合多项式。
|
||||
p = np.polyfit(s[sel], channels[selt][sel, j], deg)
|
||||
|
||||
# 绘制去趋势后的残差。
|
||||
plt.plot(timestamps_packet[selt], channels[selt, j] - np.polyval(p, s))
|
||||
plt.title("APID 1287 Spacecraft 245 Virtual channel 1 bytes 66-90 (float64)")
|
||||
plt.xlabel("Space Packet timestamp")
|
||||
plt.ylabel("Raw value (float64)")
|
||||
plt.legend(
|
||||
[
|
||||
f"Bytes {j}-{j + 8} minus degree {deg} polynomial"
|
||||
for j in range(66, 90, 8)
|
||||
]
|
||||
)
|
||||
plt.grid()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# APIDs 2 and 3
|
||||
# -------------------------------------------------------------------------
|
||||
# 原 notebook 这里两个变量都从 APID 2 取数,保留原逻辑。若要比较 APID 2
|
||||
# 与 APID 3,可把 `sc245_vc1_by_apid[2]` 的第二处改为 `[3]`。
|
||||
# channels2 是 APID 2 的 uint8 载荷矩阵。
|
||||
channels2 = packets_asarray(sc245_vc1_by_apid[2])
|
||||
|
||||
# timestamps2 是 APID 2 每个 packet 对应的 AOS 时间戳。
|
||||
timestamps2 = get_packet_timestamps(sc245_vc1_by_apid[2])
|
||||
|
||||
# 注意:这里原 notebook 仍然取 APID 2,不是真正的 APID 3。
|
||||
# 为了忠实保留原分析脚本,暂不改动。
|
||||
channels3 = packets_asarray(sc245_vc1_by_apid[2])
|
||||
|
||||
# 同上,这里的 timestamps3 也来自 APID 2。
|
||||
timestamps3 = get_packet_timestamps(sc245_vc1_by_apid[2])
|
||||
|
||||
# 对比 byte 0 的变化;'.-' 是点加线,'.' 是只画点。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps2, channels2[:, 0], ".-", label="APID 2 byte 0")
|
||||
plt.plot(timestamps3, channels3[:, 0], ".", label="APID 3 byte 0")
|
||||
plt.title("APIDs 2 and 3 Spacecraft 245 Virtual channel 1 byte 0")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (uint8)")
|
||||
plt.legend()
|
||||
plt.grid()
|
||||
|
||||
# 对比 byte 2 的变化。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps2, channels2[:, 2], ".-", label="APID 2 byte 2")
|
||||
plt.plot(timestamps3, channels3[:, 2], ".", label="APID 3 byte 2")
|
||||
plt.title("APIDs 2 and 3 Spacecraft 245 Virtual channel 1 byte 2")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (uint8)")
|
||||
plt.legend()
|
||||
plt.grid()
|
||||
|
||||
# 只取局部样本,放大查看 byte 4 的变化。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
|
||||
# 从第 14350 个样本开始取 400 个样本。
|
||||
sel = slice(14350, 14350 + 400)
|
||||
plt.plot(timestamps2[sel], channels2[sel, 4], ".-", label="APID 2 byte 4")
|
||||
plt.plot(timestamps3[sel], channels3[sel, 4], ".", label="APID 3 byte 4")
|
||||
plt.title("APIDs 2 and 3 Spacecraft 245 Virtual channel 1 byte 4")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (uint8)")
|
||||
plt.legend()
|
||||
plt.grid()
|
||||
|
||||
# 使用同一个局部范围,查看 byte 8 的变化。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps2[sel], channels2[sel, 8], ".-", label="APID 2 byte 8")
|
||||
plt.plot(timestamps3[sel], channels3[sel, 8], ".", label="APID 3 byte 8")
|
||||
plt.title("APIDs 2 and 3 Spacecraft 245 Virtual channel 1 byte 8")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (uint8)")
|
||||
plt.legend()
|
||||
plt.grid()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 454
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 454 按 uint8 查看,重点观察 byte 0 以及 byte 4-7、18-20。
|
||||
# 这里没有 view 成 int16/float64,说明按单字节原始值观察。
|
||||
channels = packets_asarray(sc245_vc1_by_apid[454])
|
||||
|
||||
# APID 454 的 packet 时间戳。
|
||||
timestamps = get_packet_timestamps(sc245_vc1_by_apid[454])
|
||||
|
||||
# 放大一小段样本,查看 byte 0。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
sel = slice(1780, 1850)
|
||||
plt.plot(timestamps[sel], channels[sel, 0], ".-")
|
||||
plt.title("APID 454 Spacecraft 245 Virtual channel 1 byte 0")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (uint8)")
|
||||
plt.grid()
|
||||
|
||||
# 在同一张图中画 byte 4-6 和 byte 18-19,比较这些字段是否同步变化。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps[sel], channels[sel, 4:7], ".-")
|
||||
plt.plot(timestamps[sel], channels[sel, 18:20], ".-")
|
||||
plt.title("APID 454 Spacecraft 245 Virtual channel 1 bytes 4-7, 18-20")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (uint8)")
|
||||
plt.legend([f"Byte {j}" for j in list(range(4, 7)) + list(range(18, 20))])
|
||||
plt.grid()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 462
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 462 按大端 int16 解释,观察前 14 字节。
|
||||
# 前 14 字节对应 7 个 int16 通道。
|
||||
channels = packets_asarray(sc245_vc1_by_apid[462]).view("int16").byteswap()
|
||||
|
||||
# APID 462 的时间戳。
|
||||
timestamps = get_packet_timestamps(sc245_vc1_by_apid[462])
|
||||
|
||||
# 绘制 7 个 int16 通道的全时段变化。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps, channels[:, :7])
|
||||
plt.title("APID 462 Spacecraft 245 Virtual channel 1 bytes 0-14")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (int16)")
|
||||
plt.legend([f"Bytes {j} and {j + 1}" for j in range(0, 14, 2)])
|
||||
plt.grid()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 1536
|
||||
# -------------------------------------------------------------------------
|
||||
# APID 1536 按大端 int16 解释,分别观察 bytes 18-20 和 20-22。
|
||||
# int16 通道 9 对应 bytes 18-20,通道 10 对应 bytes 20-22。
|
||||
channels = packets_asarray(sc245_vc1_by_apid[1536]).view("int16").byteswap()
|
||||
|
||||
# APID 1536 的时间戳。
|
||||
timestamps = get_packet_timestamps(sc245_vc1_by_apid[1536])
|
||||
|
||||
# 绘制 int16 通道 9。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps, channels[:, 9])
|
||||
plt.title("APID 1536 Spacecraft 245 Virtual channel 1 bytes 18-20")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (int16)")
|
||||
plt.grid()
|
||||
|
||||
# 绘制 int16 通道 10。
|
||||
plt.figure(figsize=(12, 6), facecolor="w")
|
||||
plt.plot(timestamps, channels[:, 10])
|
||||
plt.title("APID 1536 Spacecraft 245 Virtual channel 1 bytes 20-22")
|
||||
plt.xlabel("AOS frame timestamp")
|
||||
plt.ylabel("Raw value (int16)")
|
||||
plt.grid()
|
||||
|
||||
# notebook 里依赖自动显示图像,但脚本在 WSL / 无显示环境下不应强制调用
|
||||
# `plt.show()`,否则可能因为缺少图形界面后端而报错。这里统一用 savefig
|
||||
# 保存所有 figure,方便在 WSL 中直接查看生成的 PNG 文件。
|
||||
# OUTPUT_DIR 是 Tianwen/frame_analysis_figures。
|
||||
save_all_figures(OUTPUT_DIR)
|
||||
|
||||
|
||||
# 只有直接运行这个脚本时才会执行 main()。
|
||||
# 如果以后从别的 Python 文件 import 这个脚本,main() 不会自动运行。
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
91
test/Tianwen-1-frame-analysis.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""0.fix import problem"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 获取项目根目录(自动适配 Windows/Linux/Mac)
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
"""1.import libs"""
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
# Agg 是非交互式后端,不需要 display,图片可通过 plt.savefig() 保存为文件。现在可以恢复 matplotlib import 并正常调试了。
|
||||
import matplotlib.pyplot as plt
|
||||
# uv pip install construct
|
||||
from construct import *
|
||||
# uv pip install scipy
|
||||
import scipy.signal
|
||||
|
||||
import Tianwen.ccsds as ccsds
|
||||
import Tianwen.tianwen1_tm as tianwen1_tm
|
||||
|
||||
import struct
|
||||
import collections
|
||||
|
||||
|
||||
"""2.define some functions"""
|
||||
def get_packet(p):
|
||||
return p[0] if type(p) is tuple else p
|
||||
|
||||
def packets_asarray(packets):
|
||||
return np.array([np.frombuffer(get_packet(p)[ccsds.SpacePacketPrimaryHeader.sizeof():], 'uint8')
|
||||
for p in packets])
|
||||
|
||||
def plot_apids(apids, sc, vc):
|
||||
for apid in sorted(apids.keys()):
|
||||
plt.figure(figsize = (16,16), facecolor = 'w')
|
||||
ps = packets_asarray(apids[apid])
|
||||
plt.imshow(ps, aspect = ps.shape[1]/ps.shape[0])
|
||||
plt.title(f'APID {apid} Spacecraft {sc} Virtual channel {vc}')
|
||||
|
||||
def get_timestamps(aos):
|
||||
return np.array([tianwen1_tm.parse_timestamp_datetime64(a.insert_zone.timestamp) for a in aos])
|
||||
|
||||
def get_packet_timestamps(packets):
|
||||
return np.array([tianwen1_tm.parse_timestamp_datetime64(p[1]) for p in packets])
|
||||
|
||||
|
||||
"""3.set some frame vars"""
|
||||
frame_size = 220
|
||||
frames = np.fromfile('/home/zjz/CCSDS_study/Tianwen/tianwen1_frames_20200730.u8', dtype = 'uint8')
|
||||
frames = frames[:frames.size//frame_size*frame_size].reshape((-1, frame_size))
|
||||
print("frames.shape[0]: ", frames.shape[0])
|
||||
print("\n")
|
||||
|
||||
|
||||
"""4.AOS header"""
|
||||
aos = [ccsds.AOSFrame.parse(f) for f in frames]
|
||||
|
||||
collections.Counter([a.primary_header.transfer_frame_version_number for a in aos])
|
||||
|
||||
collections.Counter([a.primary_header.spacecraft_id for a in aos])
|
||||
|
||||
collections.Counter([a.primary_header.virtual_channel_id for a in aos
|
||||
if a.primary_header.spacecraft_id == 245])
|
||||
|
||||
collections.Counter([a.primary_header.virtual_channel_id for a in aos
|
||||
if a.primary_header.spacecraft_id == 82])
|
||||
|
||||
print([(a.primary_header.spacecraft_id, a.primary_header.virtual_channel_id) for a in aos[:20]])
|
||||
|
||||
|
||||
"""5.Spacecraft 82 Virtual channel 1"""
|
||||
print([a.primary_header for a in aos if a.primary_header.spacecraft_id == 82][:10])
|
||||
|
||||
|
||||
|
||||
"""for test"""
|
||||
# def main():
|
||||
# frame_size = 220
|
||||
# frames = np.fromfile('/home/zjz/CCSDS_study/Tianwen/tianwen1_frames_20200730.u8', dtype = 'uint8')
|
||||
# frames = frames[:frames.size//frame_size*frame_size].reshape((-1, frame_size))
|
||||
# print("frames.shape[0]: ", frames.shape[0])
|
||||
# print("\n")
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# print("hello world!")
|
||||
# main()
|
||||
|
||||
|
||||
495
test/Tianwen-1-parse-netzob.py
Normal file
@@ -0,0 +1,495 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Teach unknown-frame analysis with Netzob on Tianwen-1 raw frame data.
|
||||
|
||||
这个脚本的目标不是“直接使用已知的 Tianwen-1 / CCSDS 解析器”,而是假设我们
|
||||
只拿到一段连续的二进制帧数据,不知道具体空间帧协议,然后用 Netzob 的核心
|
||||
概念做一次可运行的协议探索教学。
|
||||
|
||||
重点演示的 Netzob 概念:
|
||||
1. RawMessage:把每一帧原始字节包装成 Netzob 消息。
|
||||
2. Symbol:把一组相似消息放进同一个协议符号。
|
||||
3. Format.splitStatic:根据样本中固定/变化的字节位置自动切字段。
|
||||
4. Format.clusterByKeyField:选择某个字段作为 key,把消息按字段值聚类。
|
||||
5. Field / Raw:在已有观察基础上,手工建立一个“候选帧格式”模型。
|
||||
|
||||
注意:
|
||||
- 本脚本不会 import Tianwen.ccsds,也不会调用 AOSFrame.parse。
|
||||
- 为了教学和运行速度,默认只抽样前 96 帧做 Netzob 推断。
|
||||
- 原始数据较大,完整协议逆向通常需要多轮实验;这里侧重方法和工具用法。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
# 让 print 尽量按行输出,便于长流程运行时看到进度。
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 路径准备
|
||||
# ---------------------------------------------------------------------------
|
||||
# 当前脚本位于 /home/zjz/CCSDS_study/test/。
|
||||
# parent.parent 回到项目根目录 /home/zjz/CCSDS_study。
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 本仓库里有一份本地 Netzob 源码/测试目录。优先使用它,保证和仓库现有示例一致。
|
||||
LOCAL_NETZOB_SRC = PROJECT_ROOT / "netzob-030" / "test" / "src"
|
||||
if LOCAL_NETZOB_SRC.exists():
|
||||
sys.path.insert(0, str(LOCAL_NETZOB_SRC))
|
||||
|
||||
# 导入 Netzob 公共 API。
|
||||
# noqa 注释是告诉代码检查器:星号导入是教学脚本为了贴近 Netzob 教程而保留。
|
||||
from netzob.all import * # noqa: F401,F403,E402
|
||||
|
||||
|
||||
# 原始天问一号帧字节文件。这里把它当成未知二进制样本,不使用已知解析器。
|
||||
DEFAULT_INPUT = PROJECT_ROOT / "Tianwen" / "tianwen1_frames_20200730.u8"
|
||||
|
||||
|
||||
def section(title: str) -> None:
|
||||
"""打印一个清晰的教学分节标题。"""
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
print(title)
|
||||
print("=" * 78)
|
||||
|
||||
|
||||
def short_hex(data: bytes, max_bytes: int = 48) -> str:
|
||||
"""把 bytes 转成短十六进制字符串,避免一帧 220 字节全部刷屏。"""
|
||||
|
||||
head = data[:max_bytes].hex(" ")
|
||||
return head + (" ..." if len(data) > max_bytes else "")
|
||||
|
||||
|
||||
def entropy(values) -> float:
|
||||
"""计算一组离散值的 Shannon entropy。
|
||||
|
||||
entropy 越低,说明这个字节位置越稳定,越像版本号、固定标识或填充。
|
||||
entropy 越高,说明这个字节位置变化越丰富,越像计数器、时间戳或载荷。
|
||||
"""
|
||||
|
||||
counts = Counter(values)
|
||||
total = len(values)
|
||||
return -sum((count / total) * math.log2(count / total) for count in counts.values())
|
||||
|
||||
|
||||
def load_raw_bytes(path: Path) -> bytes:
|
||||
"""读取原始二进制文件。"""
|
||||
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"input file not found: {path}")
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def estimate_frame_size(
|
||||
raw: bytes,
|
||||
candidate_min: int,
|
||||
candidate_max: int,
|
||||
sample_frames: int,
|
||||
header_columns: int,
|
||||
):
|
||||
"""在不知道帧长时,用“候选帧长打分”的方式找可能帧长。
|
||||
|
||||
思路很简单:
|
||||
- 如果帧长猜对了,那么每一行的开头会对齐到真实帧头。
|
||||
- 真实帧头通常包含版本号、ID、计数器等结构化字段。
|
||||
- 这些字段的熵一般比随机载荷低。
|
||||
- 所以对每个候选帧长,把数据切成多行,计算前若干列的平均熵。
|
||||
- 平均熵越低,越可能是正确帧长。
|
||||
|
||||
这不是严格证明,只是未知协议分析中常用的启发式方法。
|
||||
"""
|
||||
|
||||
results = []
|
||||
for frame_size in range(candidate_min, candidate_max + 1):
|
||||
frame_count = min(len(raw) // frame_size, sample_frames)
|
||||
if frame_count < 8:
|
||||
continue
|
||||
|
||||
frames = [
|
||||
raw[i * frame_size : (i + 1) * frame_size] for i in range(frame_count)
|
||||
]
|
||||
columns = min(header_columns, frame_size)
|
||||
entropies = []
|
||||
unique_counts = []
|
||||
for offset in range(columns):
|
||||
values = [frame[offset] for frame in frames]
|
||||
entropies.append(entropy(values))
|
||||
unique_counts.append(len(set(values)))
|
||||
|
||||
results.append(
|
||||
{
|
||||
"frame_size": frame_size,
|
||||
"avg_entropy": sum(entropies) / len(entropies),
|
||||
"avg_unique": sum(unique_counts) / len(unique_counts),
|
||||
"frame_count": frame_count,
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(results, key=lambda item: (item["avg_entropy"], item["avg_unique"]))
|
||||
|
||||
|
||||
def slice_frames(raw: bytes, frame_size: int, limit: int | None = None) -> list[bytes]:
|
||||
"""把连续字节流切成固定长度帧。"""
|
||||
|
||||
total_frames = len(raw) // frame_size
|
||||
if limit is not None:
|
||||
total_frames = min(total_frames, limit)
|
||||
return [raw[i * frame_size : (i + 1) * frame_size] for i in range(total_frames)]
|
||||
|
||||
|
||||
def build_symbol(frames: list[bytes], name: str) -> Symbol:
|
||||
"""把 bytes 帧列表包装成 Netzob RawMessage,再放入 Symbol。"""
|
||||
|
||||
messages = [RawMessage(data=frame) for frame in frames]
|
||||
symbol = Symbol(messages=messages, name=name)
|
||||
|
||||
# HexaString 让 Netzob 打印 Symbol 时用十六进制展示,更适合二进制协议。
|
||||
symbol.encodingFunctions.add(TypeEncodingFunction(HexaString))
|
||||
return symbol
|
||||
|
||||
|
||||
def byte_statistics(frames: list[bytes]) -> list[dict]:
|
||||
"""按字节偏移统计唯一值数量、熵和最常见取值。"""
|
||||
|
||||
frame_size = len(frames[0])
|
||||
stats = []
|
||||
for offset in range(frame_size):
|
||||
values = [frame[offset] for frame in frames]
|
||||
counts = Counter(values)
|
||||
stats.append(
|
||||
{
|
||||
"offset": offset,
|
||||
"unique": len(counts),
|
||||
"entropy": entropy(values),
|
||||
"top": counts.most_common(4),
|
||||
}
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def print_byte_stats(stats: list[dict], first_columns: int = 32) -> None:
|
||||
"""打印前若干字节位置的统计表。"""
|
||||
|
||||
print("offset unique entropy most common byte values")
|
||||
print("------ ------ ------- -----------------------")
|
||||
for item in stats[:first_columns]:
|
||||
top = ", ".join(f"0x{value:02x}:{count}" for value, count in item["top"])
|
||||
print(
|
||||
f"{item['offset']:>6} {item['unique']:>6} "
|
||||
f"{item['entropy']:>7.3f} {top}"
|
||||
)
|
||||
|
||||
|
||||
def print_static_dynamic_regions(stats: list[dict]) -> None:
|
||||
"""根据 unique 数量粗略标出固定区、低变化区和高变化区。"""
|
||||
|
||||
labels = []
|
||||
for item in stats:
|
||||
if item["unique"] == 1:
|
||||
labels.append("static")
|
||||
elif item["unique"] <= 8:
|
||||
labels.append("low-var")
|
||||
else:
|
||||
labels.append("dynamic")
|
||||
|
||||
regions = []
|
||||
start = 0
|
||||
current = labels[0]
|
||||
for index, label in enumerate(labels[1:], start=1):
|
||||
if label != current:
|
||||
regions.append((start, index - 1, current))
|
||||
start = index
|
||||
current = label
|
||||
regions.append((start, len(labels) - 1, current))
|
||||
|
||||
print("candidate byte regions from simple statistics:")
|
||||
for start, end, label in regions[:40]:
|
||||
width = end - start + 1
|
||||
print(f" bytes {start:03d}-{end:03d} width={width:03d} {label}")
|
||||
if len(regions) > 40:
|
||||
print(f" ... {len(regions) - 40} more regions omitted")
|
||||
|
||||
|
||||
def demonstrate_split_static(frames: list[bytes]) -> Symbol:
|
||||
"""用 Netzob splitStatic 展示自动字段切分。"""
|
||||
|
||||
symbol = build_symbol(frames, "unknown_tianwen_frames")
|
||||
print("Before splitStatic, Netzob sees one raw field:")
|
||||
print(f" number of fields: {len(symbol.fields)}")
|
||||
|
||||
# splitStatic 会比较同一 Symbol 下所有消息的每个位置:
|
||||
# - 所有样本都相同的位置会变成 static field。
|
||||
# - 样本之间变化的位置会变成 dynamic field。
|
||||
Format.splitStatic(
|
||||
symbol,
|
||||
unitSize=UnitSize.SIZE_8,
|
||||
mergeAdjacentStaticFields=True,
|
||||
mergeAdjacentDynamicFields=True,
|
||||
)
|
||||
|
||||
print("\nAfter splitStatic(unitSize=8, merge adjacent static/dynamic fields):")
|
||||
print(f" number of inferred fields: {len(symbol.fields)}")
|
||||
print(
|
||||
" teaching note: if most byte positions vary at least once, adjacent "
|
||||
"dynamic bytes can merge into one large dynamic field."
|
||||
)
|
||||
|
||||
bytewise_symbol = build_symbol(frames, "unknown_tianwen_frames_bytewise")
|
||||
Format.splitStatic(
|
||||
bytewise_symbol,
|
||||
unitSize=UnitSize.SIZE_8,
|
||||
mergeAdjacentStaticFields=False,
|
||||
mergeAdjacentDynamicFields=False,
|
||||
)
|
||||
|
||||
print("\nAfter splitStatic(unitSize=8, do not merge adjacent fields):")
|
||||
print(f" number of inferred byte-level fields: {len(bytewise_symbol.fields)}")
|
||||
print(" first inferred field labels:")
|
||||
for index, field in enumerate(bytewise_symbol.fields[:24]):
|
||||
print(f" field[{index:02d}] {field}")
|
||||
if len(bytewise_symbol.fields) > 24:
|
||||
print(f" ... {len(bytewise_symbol.fields) - 24} more fields")
|
||||
return bytewise_symbol
|
||||
|
||||
|
||||
def build_bytewise_symbol(frames: list[bytes]) -> Symbol:
|
||||
"""把每个字节都切成独立 Field,方便选择某个 offset 做聚类 key。"""
|
||||
|
||||
symbol = build_symbol(frames, "unknown_tianwen_frames_bytewise")
|
||||
Format.splitStatic(
|
||||
symbol,
|
||||
unitSize=UnitSize.SIZE_8,
|
||||
mergeAdjacentStaticFields=False,
|
||||
mergeAdjacentDynamicFields=False,
|
||||
)
|
||||
return symbol
|
||||
|
||||
|
||||
def demonstrate_cluster_by_key_field(
|
||||
frames: list[bytes], stats: list[dict], cluster_sample_size: int
|
||||
) -> None:
|
||||
"""演示如何用某个候选字段作为 key 进行 Netzob 聚类。"""
|
||||
|
||||
# 在未知协议中,低变化字段常常适合作为聚类 key,比如版本、航天器 ID、
|
||||
# 虚拟信道 ID、消息类型等。这里先用统计找出一些候选 offset。
|
||||
candidates = [
|
||||
item
|
||||
for item in stats[:32]
|
||||
if 1 < item["unique"] <= 12
|
||||
]
|
||||
candidates = sorted(candidates, key=lambda item: (item["unique"], item["entropy"]))
|
||||
|
||||
if not candidates:
|
||||
print("No low-variation key candidates found in the first 32 bytes.")
|
||||
return
|
||||
|
||||
print("candidate key byte offsets from the first 32 bytes:")
|
||||
for item in candidates[:8]:
|
||||
top = ", ".join(f"0x{value:02x}:{count}" for value, count in item["top"])
|
||||
print(
|
||||
f" offset {item['offset']:02d}: unique={item['unique']}, "
|
||||
f"entropy={item['entropy']:.3f}, top=[{top}]"
|
||||
)
|
||||
|
||||
# 选择最靠前且变化种类较少的字段作为演示 key。
|
||||
key_offset = candidates[0]["offset"]
|
||||
cluster_frames = frames[:cluster_sample_size]
|
||||
print(
|
||||
f" using {len(cluster_frames)} frames for this cluster demo "
|
||||
f"(kept small because Netzob clustering can be expensive)"
|
||||
)
|
||||
|
||||
bytewise_symbol = build_bytewise_symbol(cluster_frames)
|
||||
|
||||
print(f"\nNetzob clusterByKeyField demo on byte offset {key_offset}:")
|
||||
print(f" bytewise fields available: {len(bytewise_symbol.fields)}")
|
||||
|
||||
# clusterByKeyField 会把拥有相同 key 字段值的消息放进同一个 Symbol。
|
||||
clusters = Format.clusterByKeyField(bytewise_symbol, bytewise_symbol.fields[key_offset])
|
||||
|
||||
print(f" clusters created: {len(clusters)}")
|
||||
for key, cluster_symbol in list(clusters.items())[:12]:
|
||||
key_hex = key.hex() if isinstance(key, (bytes, bytearray)) else str(key)
|
||||
print(
|
||||
f" key=0x{key_hex:<4} messages={len(cluster_symbol.messages):>4} "
|
||||
f"fields={len(cluster_symbol.fields)}"
|
||||
)
|
||||
|
||||
|
||||
def demonstrate_manual_candidate_model(frame_size: int) -> None:
|
||||
"""用 Netzob Field/Raw 手工搭建一个候选格式模型。
|
||||
|
||||
这一步不是声称字段含义已经确定,而是演示逆向分析常见工作流:
|
||||
先用统计和 splitStatic 找到疑似字段边界,再用 Netzob 明确描述一个候选模型。
|
||||
"""
|
||||
|
||||
section("Step 7 - Manual candidate model with Netzob Field/Raw")
|
||||
|
||||
# 这里故意使用“candidate_”前缀,表示这些字段只是初步假设。
|
||||
# 对未知空间帧,通常可以先把开头若干字节当成候选头部,
|
||||
# 中间大段当成候选数据区,末尾若干字节当成候选尾部/校验/填充。
|
||||
candidate_header = Field(Raw(nbBytes=6), name="candidate_header_0_5")
|
||||
candidate_insert_or_secondary = Field(
|
||||
Raw(nbBytes=8), name="candidate_insert_or_secondary_6_13"
|
||||
)
|
||||
candidate_payload = Field(
|
||||
Raw(nbBytes=max(frame_size - 18, 0)), name="candidate_payload"
|
||||
)
|
||||
candidate_tail = Field(Raw(nbBytes=4), name="candidate_tail_4_bytes")
|
||||
|
||||
symbol = Symbol(
|
||||
name="manual_candidate_tianwen_like_frame",
|
||||
fields=[
|
||||
candidate_header,
|
||||
candidate_insert_or_secondary,
|
||||
candidate_payload,
|
||||
candidate_tail,
|
||||
],
|
||||
)
|
||||
|
||||
print("This is a teaching model, not a confirmed Tianwen-1 specification:")
|
||||
print(symbol.str_structure())
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""命令行参数。"""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Use Netzob to teach unknown binary frame analysis on Tianwen-1 raw data."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
type=Path,
|
||||
default=DEFAULT_INPUT,
|
||||
help=f"raw binary input file, default: {DEFAULT_INPUT}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frame-size",
|
||||
type=int,
|
||||
default=None,
|
||||
help=(
|
||||
"known or chosen frame size. If omitted, the script estimates it "
|
||||
"from candidate sizes."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--candidate-min",
|
||||
type=int,
|
||||
default=180,
|
||||
help="minimum frame-size candidate used when estimating frame length",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--candidate-max",
|
||||
type=int,
|
||||
default=260,
|
||||
help="maximum frame-size candidate used when estimating frame length",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample-size",
|
||||
type=int,
|
||||
default=96,
|
||||
help="number of frames used for Netzob analysis",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show-samples",
|
||||
type=int,
|
||||
default=4,
|
||||
help="number of raw sample frames to print as short hex",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cluster-sample-size",
|
||||
type=int,
|
||||
default=8,
|
||||
help="number of frames used only for the Netzob clusterByKeyField demo",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the teaching analysis."""
|
||||
|
||||
args = parse_args()
|
||||
|
||||
section("Step 1 - Read unknown binary data")
|
||||
raw = load_raw_bytes(args.input)
|
||||
print(f"input file: {args.input}")
|
||||
print(f"total bytes: {len(raw):,}")
|
||||
|
||||
section("Step 2 - Estimate or choose a fixed frame size")
|
||||
if args.frame_size is None:
|
||||
ranked = estimate_frame_size(
|
||||
raw,
|
||||
candidate_min=args.candidate_min,
|
||||
candidate_max=args.candidate_max,
|
||||
sample_frames=args.sample_size,
|
||||
header_columns=16,
|
||||
)
|
||||
print("Top frame-size candidates by low average header entropy:")
|
||||
for item in ranked[:10]:
|
||||
print(
|
||||
f" size={item['frame_size']:>3} "
|
||||
f"avg_entropy={item['avg_entropy']:.3f} "
|
||||
f"avg_unique={item['avg_unique']:.2f} "
|
||||
f"frames_tested={item['frame_count']}"
|
||||
)
|
||||
frame_size = ranked[0]["frame_size"]
|
||||
print(f"\nChosen frame size for the rest of this teaching run: {frame_size}")
|
||||
else:
|
||||
frame_size = args.frame_size
|
||||
print(f"Using user-provided frame size: {frame_size}")
|
||||
|
||||
all_frame_count = len(raw) // frame_size
|
||||
frames = slice_frames(raw, frame_size, limit=args.sample_size)
|
||||
print(f"complete frames in file with this size: {all_frame_count:,}")
|
||||
print(f"frames sampled for Netzob: {len(frames):,}")
|
||||
|
||||
print("\nFirst sample frames as short hex:")
|
||||
for index, frame in enumerate(frames[: args.show_samples]):
|
||||
print(f" frame[{index:03d}] {short_hex(frame)}")
|
||||
|
||||
section("Step 3 - Wrap samples as Netzob RawMessage and Symbol")
|
||||
teaching_symbol = build_symbol(frames, "unknown_tianwen_frames")
|
||||
print("Netzob objects created:")
|
||||
print(f" RawMessage count: {len(teaching_symbol.messages)}")
|
||||
print(f" Symbol name: {teaching_symbol.name}")
|
||||
print(f" Initial field count: {len(teaching_symbol.fields)}")
|
||||
print(
|
||||
"Teaching point: at the beginning Netzob only knows each frame is raw bytes; "
|
||||
"it does not know the protocol fields."
|
||||
)
|
||||
|
||||
section("Step 4 - Byte-position statistics before protocol knowledge")
|
||||
stats = byte_statistics(frames)
|
||||
print_byte_stats(stats, first_columns=40)
|
||||
print()
|
||||
print_static_dynamic_regions(stats)
|
||||
|
||||
section("Step 5 - Netzob Format.splitStatic field inference")
|
||||
demonstrate_split_static(frames)
|
||||
|
||||
section("Step 6 - Netzob clusterByKeyField on candidate key bytes")
|
||||
demonstrate_cluster_by_key_field(frames, stats, args.cluster_sample_size)
|
||||
|
||||
demonstrate_manual_candidate_model(frame_size)
|
||||
|
||||
section("Done - What to try next")
|
||||
print(
|
||||
"1. Increase --sample-size to see whether inferred fields remain stable.\n"
|
||||
"2. Try --frame-size with another candidate and compare splitStatic results.\n"
|
||||
"3. Choose another low-variation offset as cluster key and inspect clusters.\n"
|
||||
"4. After a candidate field map is stable, then compare it with known CCSDS/Tianwen parsing.\n"
|
||||
"5. Treat this as protocol-discovery scaffolding, not as a final specification."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
test/__pycache__/Tianwen-1-frame-analysis-v2.cpython-38.pyc
Normal file
BIN
test/__pycache__/Tianwen-1-parse-netzob.cpython-38.pyc
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 727 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 411 KiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 657 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 595 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 702 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 953 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 684 KiB |
|
After Width: | Height: | Size: 479 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 417 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 69 KiB |
107
test/netzob_test_folder_analysis.md
Normal file
@@ -0,0 +1,107 @@
|
||||
以下是 `/home/zjz/CCSDS_study/netzob-030/test` 目录的详细分析:
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
test/
|
||||
├── README_TEST.rst
|
||||
├── resources/
|
||||
│ ├── files/ # 原始消息文件(dat/txt)
|
||||
│ ├── pcaps/ # PCAP抓包文件
|
||||
│ └── test_netzob/ # 各模块专用测试向量
|
||||
│ ├── test_Checksums/ # CRC测试向量
|
||||
│ ├── test_Hashes/ # NIST CAVS哈希测试向量
|
||||
│ └── test_Hmacs/ # NIST HMAC测试向量
|
||||
└── src/
|
||||
├── common/ # 测试基础设施
|
||||
└── test_netzob/ # 所有测试模块
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试入口与基础设施
|
||||
|
||||
**`suite_global.py`** — 全局入口,当前**只激活了 `suite_DocTests`**,其余套件被注释掉。
|
||||
|
||||
**`NetzobTestCase.py`** — 所有测试的基类,继承 `unittest.TestCase`。
|
||||
|
||||
**`xmlrunner.py`** — JUnit 兼容的 XML 报告输出器,用于 CI 集成。
|
||||
|
||||
---
|
||||
|
||||
## 核心测试套件
|
||||
|
||||
### 1. suite_DocTests(唯一激活的套件)
|
||||
|
||||
从 50+ 个源码模块的 docstring 中提取 doctest,覆盖:
|
||||
|
||||
| 模块类别 | 代表模块 |
|
||||
| ----------- | ------------------------------------------------------------ |
|
||||
| 类型系统 | `String`, `Integer`, `BitArray`, `Raw`, `IPv4`, `Timestamp` |
|
||||
| 词汇表/格式 | `Symbol`, `Field`, `Protocol`, `Alt`, `Agg`, `Repeat` |
|
||||
| 字段切分 | `FieldSplitStatic`, `FieldSplitAligned`, `FieldSplitDelimiter` |
|
||||
| 聚类推断 | `ClusterByKeyField`, `ClusterByAlignment`, `ClusterBySize` |
|
||||
| 密码学字段 | HMAC×5, Hash×6, CRC×6 |
|
||||
| 状态机 | `State`, `Transition`, `Automata`, `AbstractionLayer` |
|
||||
| 模糊测试 | 13种 Mutator + 4种 Generator |
|
||||
| 协议仿真 | `Actor`, `TCPServer/Client`, `UDPServer/Client`, `SSLClient` |
|
||||
| 导入导出 | `PCAPImporter`, `FileImporter` |
|
||||
|
||||
---
|
||||
|
||||
### 2. test_Tutorials(教程集成测试)
|
||||
|
||||
| 文件 | 功能 |
|
||||
| ---------------------------- | ---------------------------------------------------- |
|
||||
| `test_ICMP.py` | 构建 ICMP 报文,含 CRC16 自动计算校验和 |
|
||||
| `test_USBMouseProtocol.py` | 对 USB 鼠标数据包做 `splitStatic()` 字段推断 |
|
||||
| `test_ImportPCAP.py` | 从 `botnet_irc_bot.pcap` 导入17条消息,测试 BPF 过滤 |
|
||||
| `test_StaticFieldSlicing.py` | 不同粒度(SIZE_8/16)和合并策略的字段切分 |
|
||||
| `test_ManualFieldStatic.py` | 自动切分后手动 `mergeFields()` 合并字段 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 密码学正确性测试(使用 NIST 标准向量)
|
||||
|
||||
**`test_Checksums.py`** — 6种CRC:CRC16、CRC16DNP、CRC16Kermit、CRC16SICK、CRC32、CRCCCITT
|
||||
|
||||
**`test_Hashes.py`** — 6种哈希:MD5、SHA1、SHA2-224/256/384/512(短消息+长消息)
|
||||
|
||||
**`test_Hmacs.py`** — 5种HMAC:HMAC-SHA1/224/256/384/512
|
||||
|
||||
验证方式:构建 `Symbol([data_field, crypto_field])`,`specialize()` 后与 NIST 预期值比对。
|
||||
|
||||
---
|
||||
|
||||
### 4. test_Common(单元测试)
|
||||
|
||||
| 文件 | 测试内容 |
|
||||
| ------------------------ | ------------------------------------------------------------ |
|
||||
| `test_Field.py` | Field 的7种属性(格式/字节序/符号/编码/可视化/变换函数) |
|
||||
| `test_Encoding.py` | FormatFunction 在 HEX/STRING/DECIMAL/OCTAL/BINARY × 3种单位大小下的输出 |
|
||||
| `test_TypeIdentifier.py` | 自动识别数字/字母/Base64/十六进制字符串类型 |
|
||||
| `test_Endianness.py` | 字节序常量值验证 |
|
||||
|
||||
---
|
||||
|
||||
### 5. test_Alignment(对齐算法)
|
||||
|
||||
- **`test_Needleman.py`** — Needleman-Wunsch 语义对齐,含回归测试(验证已知 bug 不再出现)
|
||||
- **`test_UPGMA.py`** — UPGMA 层次聚类,验证法语问候消息能正确分组
|
||||
- **`test_NeedlemanInC.py` / `test_UPGMAInC.py`** — 对应 C 语言加速版本的测试
|
||||
|
||||
---
|
||||
|
||||
### 6. 其他
|
||||
|
||||
**`test_public_api.py`** — 通过 AST 解析验证所有 `@public_api` 方法都有完整类型注解(当前宽松模式,失败只打印不报错)。
|
||||
|
||||
**`test_Export/test_ScapyExporter.py`** — 验证将 Symbol 导出为 Scapy 协议定义 Python 文件。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
测试目录以 **doctest 为主要测试方式**(测试即文档),辅以密码学正确性测试(NIST 标准向量)和教程式集成测试。当前只有 `suite_DocTests` 被激活,其余套件处于注释状态。密码学模块(Hash/HMAC/CRC)的测试最为严格,使用官方标准向量逐条验证。
|
||||
38
test/run_manual_field_static.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import binascii
|
||||
import logging
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/home/zjz/CCSDS_study/netzob-030/test/src')
|
||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||
|
||||
from netzob.all import *
|
||||
|
||||
samples = ["00ff1f000000", "00fe1f000000", "00fe1f000000", "00fe1f000000",
|
||||
"00ff1f000000", "00ff1f000000", "00ff0f000000", "00fe2f000000"]
|
||||
|
||||
messages = [RawMessage(data=binascii.unhexlify(s)) for s in samples]
|
||||
symbol = Symbol(messages=messages)
|
||||
symbol.encodingFunctions.add(TypeEncodingFunction(HexaString))
|
||||
|
||||
print("=== 原始消息 ===")
|
||||
print(symbol)
|
||||
|
||||
# 按 SIZE_8 切分为 6 个字段,静态/动态均不合并
|
||||
Format.splitStatic(symbol, unitSize=UnitSize.SIZE_8,
|
||||
mergeAdjacentStaticFields=False,
|
||||
mergeAdjacentDynamicFields=False)
|
||||
print("\n=== SIZE_8 切分后(6个字段)===")
|
||||
print(symbol)
|
||||
|
||||
# 手动合并 Field[1] 和 Field[2]
|
||||
Format.mergeFields(symbol.children[1], symbol.children[2])
|
||||
print("\n=== 合并 Field[1]+Field[2] 后 ===")
|
||||
print(symbol)
|
||||
|
||||
# 手动合并 Field[2] 和 Field[3]
|
||||
Format.mergeFields(symbol.children[2], symbol.children[3])
|
||||
print("\n=== 合并 Field[2]+Field[3] 后 ===")
|
||||
print(symbol)
|
||||
|
||||
print("\n=== 字段结构调试信息 ===")
|
||||
print(symbol._str_debug())
|
||||
40
test/run_static_field_slicing.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import binascii
|
||||
import logging
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/home/zjz/CCSDS_study/netzob-030/test/src')
|
||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||
|
||||
from netzob.all import *
|
||||
|
||||
samples = ["00ff1f000000", "00fe1f000000", "00fe1f000000", "00fe1f000000",
|
||||
"00ff1f000000", "00ff1f000000", "00ff0f000000", "00fe2f000000"]
|
||||
|
||||
messages = [RawMessage(data=binascii.unhexlify(s)) for s in samples]
|
||||
symbol = Symbol(messages=messages)
|
||||
symbol.encodingFunctions.add(TypeEncodingFunction(HexaString))
|
||||
|
||||
print("=== 原始消息 ===")
|
||||
print(symbol)
|
||||
|
||||
print("\n=== 默认 splitStatic ===")
|
||||
Format.splitStatic(symbol)
|
||||
print(symbol)
|
||||
|
||||
print("\n=== SIZE_8,静态/动态字段均不合并 ===")
|
||||
Format.splitStatic(symbol, unitSize=UnitSize.SIZE_8,
|
||||
mergeAdjacentStaticFields=False,
|
||||
mergeAdjacentDynamicFields=False)
|
||||
print(symbol)
|
||||
|
||||
print("\n=== SIZE_16,静态/动态字段均不合并 ===")
|
||||
Format.splitStatic(symbol, unitSize=UnitSize.SIZE_16,
|
||||
mergeAdjacentStaticFields=False,
|
||||
mergeAdjacentDynamicFields=False)
|
||||
print(symbol)
|
||||
|
||||
print("\n=== SIZE_16,合并相邻动态字段 ===")
|
||||
Format.splitStatic(symbol, unitSize=UnitSize.SIZE_16,
|
||||
mergeAdjacentStaticFields=False,
|
||||
mergeAdjacentDynamicFields=True)
|
||||
print(symbol)
|
||||
283
test/run_toy_protocol.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# run_toy_protocol.py 功能讲解
|
||||
|
||||
`run_toy_protocol.py` 是一个基于 Netzob 的玩具协议推断示例脚本。它参考 `discover_features.rst` 教程,把 PCAP 中捕获到的消息导入后,逐步完成字段切分、按命令聚类、字段对齐、长度关系推断,以及协议状态机生成。
|
||||
|
||||
脚本建模的协议格式如下:
|
||||
|
||||
```text
|
||||
<CMD/RES name> '#' <4-byte little-endian length> [<data>]
|
||||
```
|
||||
|
||||
也就是说,每条消息由三类内容组成:
|
||||
|
||||
- `CMD/RES name`:命令或响应名称,例如 `CMDidentify`、`RESencrypt`。
|
||||
- `#`:固定分隔符,用来区分命令名和后续载荷部分。
|
||||
- `4-byte little-endian length`:4 字节小端长度字段。
|
||||
- `[data]`:可选数据字段,长度通常由前面的长度字段描述。
|
||||
|
||||
## 依赖与输入数据
|
||||
|
||||
脚本开头通过 `sys.path.insert` 把本地 Netzob 测试源码目录加入 Python 模块搜索路径:
|
||||
|
||||
```python
|
||||
sys.path.insert(0, '/home/zjz/CCSDS_study/netzob-030/test/src')
|
||||
```
|
||||
|
||||
随后用:
|
||||
|
||||
```python
|
||||
from netzob.all import *
|
||||
```
|
||||
|
||||
导入 Netzob 的主要 API。该脚本依赖本地目录 `/home/zjz/CCSDS_study/netzob-030/`,而不是项目 `pyproject.toml` 中声明的普通包依赖。
|
||||
|
||||
PCAP 文件目录固定为:
|
||||
|
||||
```python
|
||||
PCAP_DIR = "/home/zjz/CCSDS_study/netzob-030/test/resources/pcaps"
|
||||
```
|
||||
|
||||
脚本读取三个会话文件:
|
||||
|
||||
- `target_src_v1_session1.pcap`
|
||||
- `target_src_v1_session2.pcap`
|
||||
- `target_src_v1_session3.pcap`
|
||||
|
||||
其中 `session1` 和 `session2` 被合并为训练消息集合:
|
||||
|
||||
```python
|
||||
messages = messages_session1 + messages_session2
|
||||
```
|
||||
|
||||
`session3` 没有参与字段推断和聚类,主要用于最后生成 PTA 自动机时展示另一条会话路径。
|
||||
|
||||
## Step 1:从 PCAP 导入消息
|
||||
|
||||
脚本使用 Netzob 的 `PCAPImporter.readFile` 读取 PCAP:
|
||||
|
||||
```python
|
||||
messages_session1 = list(PCAPImporter.readFile(...).values())
|
||||
```
|
||||
|
||||
`PCAPImporter.readFile` 返回的是一个类似字典的对象,脚本只取其中的 `.values()`,并转成列表。三个列表分别代表三个会话中的原始消息。
|
||||
|
||||
这一阶段会打印:
|
||||
|
||||
- 每个 session 中的消息数量。
|
||||
- `session1 + session2` 中所有消息的内容。
|
||||
|
||||
这一步的作用是把网络抓包中的实际报文转成 Netzob 可以分析的消息对象。
|
||||
|
||||
## Step 2:按 `#` 分隔符切分字段
|
||||
|
||||
脚本先把所有训练消息放入一个通用 `Symbol`:
|
||||
|
||||
```python
|
||||
symbol = Symbol(messages=messages)
|
||||
```
|
||||
|
||||
在 Netzob 中,`Symbol` 可以理解为一组具有相似格式的消息抽象。初始时,所有消息都被放在同一个符号中,字段结构还没有细分。
|
||||
|
||||
随后调用:
|
||||
|
||||
```python
|
||||
Format.splitDelimiter(symbol, String("#"))
|
||||
```
|
||||
|
||||
这会根据字符 `#` 把消息切成多个字段。对于本协议,`#` 前面是命令或响应名称,`#` 后面是长度字段和可选数据。
|
||||
|
||||
切分后,典型字段大致可以理解为:
|
||||
|
||||
```text
|
||||
field[0] = CMD/RES name
|
||||
field[1] = '#'
|
||||
field[2] = length + data
|
||||
```
|
||||
|
||||
脚本会打印切分后的 `symbol`,用于观察 Netzob 推断出的字段结构。
|
||||
|
||||
## Step 3:按命令名字段聚类
|
||||
|
||||
切分出第一个字段后,脚本使用它作为 key field:
|
||||
|
||||
```python
|
||||
symbols = Format.clusterByKeyField(symbol, symbol.fields[0])
|
||||
```
|
||||
|
||||
这一步会根据 `field[0]` 的取值把消息拆成多个更具体的 `Symbol`。例如:
|
||||
|
||||
- `CMDidentify` 消息会进入一个单独符号。
|
||||
- `RESidentify` 消息会进入另一个符号。
|
||||
- `CMDencrypt`、`RESencrypt`、`CMDdecrypt` 等也会分别形成自己的符号。
|
||||
|
||||
这样做的原因是,不同命令或响应虽然共享大体格式,但其载荷语义可能不同。按命令名聚类后,后续字段对齐和长度关系推断会更准确。
|
||||
|
||||
这一阶段会打印:
|
||||
|
||||
- 聚类后符号数量。
|
||||
- 每个符号的名称。
|
||||
|
||||
## Step 4:对载荷字段做序列对齐
|
||||
|
||||
脚本遍历每个聚类后的符号:
|
||||
|
||||
```python
|
||||
for name, sym in symbols.items():
|
||||
if len(sym.fields) >= 3:
|
||||
Format.splitAligned(sym.fields[2], doInternalSlick=True)
|
||||
```
|
||||
|
||||
这里重点处理 `sym.fields[2]`,也就是 `#` 后面的部分。根据协议格式,这个字段内部通常包含:
|
||||
|
||||
```text
|
||||
4 字节长度字段 + 可选数据字段
|
||||
```
|
||||
|
||||
`Format.splitAligned` 会对同一符号下的多条消息做序列对齐,尝试把稳定部分、变化部分、长度字段、数据字段进一步拆开。
|
||||
|
||||
例如对于不同长度的 `CMDencrypt` 消息:
|
||||
|
||||
```text
|
||||
CMDencrypt#\x06\x00\x00\x00abcdef
|
||||
CMDencrypt#\x0a\x00\x00\x00123456test
|
||||
```
|
||||
|
||||
序列对齐有机会把 `\x06\x00\x00\x00` / `\x0a\x00\x00\x00` 识别为一个长度相关字段,把后面的 `abcdef` / `123456test` 识别为数据字段。
|
||||
|
||||
`doInternalSlick=True` 表示启用内部清理或简化逻辑,让字段切分结果更规整。
|
||||
|
||||
这一阶段会打印每个符号对齐后的结构。
|
||||
|
||||
## Step 5:查找并应用长度关系
|
||||
|
||||
脚本使用:
|
||||
|
||||
```python
|
||||
rels = RelationFinder.findOnSymbol(sym)
|
||||
```
|
||||
|
||||
对每个符号查找字段间关系。这里最重要的是长度关系,也就是某个字段的数值描述另一个字段的长度。
|
||||
|
||||
当找到关系时,脚本会打印类似信息:
|
||||
|
||||
```text
|
||||
<relation_type>: '<x_attribute>' <-> '<y_attribute>'
|
||||
```
|
||||
|
||||
随后脚本应用找到的第一个关系:
|
||||
|
||||
```python
|
||||
rels[0]["x_fields"][0].domain = Size(rels[0]["y_fields"], factor=1/8.0)
|
||||
```
|
||||
|
||||
这行代码的含义是:把关系中的 `x_fields[0]` 设置为一个 `Size` 域,使它表示 `y_fields` 的大小。
|
||||
|
||||
`factor=1/8.0` 的作用是把位数换算成字节数。Netzob 内部很多大小计算以 bit 为单位,而协议里的长度字段表示的是 byte 数,因此需要除以 8。
|
||||
|
||||
应用关系后,脚本专门打印 `CMDencrypt` 的调试结构:
|
||||
|
||||
```python
|
||||
print(symbols["CMDencrypt"]._str_debug())
|
||||
```
|
||||
|
||||
这是为了展示长度关系被写回符号模型之后,字段域发生了什么变化。
|
||||
|
||||
## Step 6:生成协议自动机
|
||||
|
||||
完成消息格式推断后,脚本把所有符号转成列表:
|
||||
|
||||
```python
|
||||
sym_list = list(symbols.values())
|
||||
```
|
||||
|
||||
然后基于会话序列生成三类自动机。
|
||||
|
||||
### Step 6a:链式状态自动机
|
||||
|
||||
```python
|
||||
session1 = Session(messages_session1)
|
||||
abstract1 = session1.abstract(sym_list)
|
||||
automata_chained = Automata.generateChainedStatesAutomata(abstract1, sym_list)
|
||||
```
|
||||
|
||||
`Session(messages_session1)` 表示一条实际通信会话。`session1.abstract(sym_list)` 会把原始消息序列映射为符号序列,例如把某条具体消息抽象成 `CMDidentify` 或 `RESidentify`。
|
||||
|
||||
`generateChainedStatesAutomata` 会按 `session1` 的顺序生成链式状态机。它更接近“这次会话实际发生了什么”,每一步消息转换通常对应一段状态迁移。
|
||||
|
||||
脚本用:
|
||||
|
||||
```python
|
||||
automata_chained.generateDotCode()
|
||||
```
|
||||
|
||||
输出 Graphviz DOT 格式文本。
|
||||
|
||||
### Step 6b:单状态自动机
|
||||
|
||||
```python
|
||||
automata_one = Automata.generateOneStateAutomata(abstract1, sym_list)
|
||||
```
|
||||
|
||||
单状态自动机把消息符号都挂在一个状态周围,结构更粗略。它强调“这些消息都可能出现”,但弱化了严格顺序关系。
|
||||
|
||||
这种形式适合做简单协议建模或快速查看符号集合,但表达会话流程的能力不如链式状态机。
|
||||
|
||||
### Step 6c:PTA 自动机
|
||||
|
||||
```python
|
||||
session3 = Session(messages_session3)
|
||||
abstract3 = session3.abstract(sym_list)
|
||||
automata_pta = Automata.generatePTAAutomata([abstract1, abstract3], sym_list)
|
||||
```
|
||||
|
||||
PTA 是 Prefix Tree Acceptor,即前缀树接收器。这里它基于 `session1` 和 `session3` 两条抽象会话生成。
|
||||
|
||||
与只使用一条 session 的链式自动机相比,PTA 可以表达多条会话之间的共同前缀和分支路径。脚本中特意用 `session3` 参与 PTA 生成,是为了展示不同会话路径如何合并进同一个状态模型。
|
||||
|
||||
最后同样输出 DOT 代码,可用 Graphviz 转成图片:
|
||||
|
||||
```bash
|
||||
python test/run_toy_protocol.py | dot -Tsvg -o out.svg
|
||||
```
|
||||
|
||||
不过脚本的标准输出不只包含 DOT,也包含大量说明文字和中间结构。因此如果要直接交给 `dot`,通常需要先单独提取某一段 DOT 输出,或者修改脚本只输出目标自动机的 DOT 内容。
|
||||
|
||||
## 整体执行流程
|
||||
|
||||
完整流程可以概括为:
|
||||
|
||||
1. 从三个 PCAP 文件导入原始消息。
|
||||
2. 用 `session1 + session2` 作为训练数据。
|
||||
3. 把所有训练消息先放进一个通用 `Symbol`。
|
||||
4. 用 `#` 分隔符切出命令名和载荷部分。
|
||||
5. 根据命令名字段把消息聚类成多个协议符号。
|
||||
6. 对每类消息的载荷字段做序列对齐,进一步拆分长度和数据。
|
||||
7. 自动查找长度字段与数据字段之间的关系。
|
||||
8. 把找到的长度关系写回符号模型。
|
||||
9. 把 session 中的具体消息抽象为符号序列。
|
||||
10. 生成链式、单状态和 PTA 三种协议自动机。
|
||||
|
||||
## 脚本输出内容
|
||||
|
||||
运行脚本时,输出主要分为几类:
|
||||
|
||||
- PCAP 导入结果:每个 session 的消息数量和训练消息列表。
|
||||
- 字段切分结果:`splitDelimiter('#')` 后的 `Symbol`。
|
||||
- 聚类结果:按命令名拆出来的符号列表。
|
||||
- 序列对齐结果:每个符号内部的字段结构。
|
||||
- 关系推断结果:字段间的长度关系。
|
||||
- `CMDencrypt` 调试结构:展示应用 `Size` 关系后的模型。
|
||||
- 三段 DOT 代码:分别对应链式状态自动机、单状态自动机和 PTA 自动机。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 路径是硬编码的,脚本只能在当前仓库布局下直接运行。
|
||||
- 依赖的 `netzob` 来自本地 `netzob-030/test/src`,不是标准安装路径。
|
||||
- `messages` 只包含 `session1` 和 `session2`,因此字段模型主要由这两个会话推断出来。
|
||||
- `session3` 只在 PTA 自动机阶段使用,用于引入另一条会话路径。
|
||||
- `rels[0]` 只应用每个符号找到的第一个关系,如果一个符号存在多个候选关系,脚本不会逐一验证。
|
||||
- DOT 输出和普通日志混在一起,直接管道到 `dot` 可能不能得到有效图像,需要提取 DOT 段或调整脚本输出。
|
||||
|
||||
## 这个文件的定位
|
||||
|
||||
这个脚本不是面向生产环境的协议解析器,而是一个学习和演示脚本。它展示了 Netzob 如何从示例流量中推断协议结构,并进一步生成协议状态机。对理解协议逆向、字段抽象、长度关系建模和会话自动机生成很有帮助。
|
||||
126
test/run_toy_protocol.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Toy Protocol Inference - based on discover_features.rst tutorial
|
||||
|
||||
Protocol structure:
|
||||
<CMD/RES name> '#' <4-byte little-endian length> [<data>]
|
||||
|
||||
Steps covered:
|
||||
1. Import messages from PCAP files
|
||||
2. Split by '#' delimiter
|
||||
3. Cluster by key field (command name)
|
||||
4. Sequence alignment on payload field
|
||||
5. Find and apply size relations
|
||||
6. Generate automata (chained / one-state / PTA)
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '/home/zjz/CCSDS_study/netzob-030/test/src')
|
||||
|
||||
from netzob.all import *
|
||||
|
||||
PCAP_DIR = "/home/zjz/CCSDS_study/netzob-030/test/resources/pcaps"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Import messages from PCAP files
|
||||
# ---------------------------------------------------------------------------
|
||||
messages_session1 = list(PCAPImporter.readFile(f"{PCAP_DIR}/target_src_v1_session1.pcap").values())
|
||||
messages_session2 = list(PCAPImporter.readFile(f"{PCAP_DIR}/target_src_v1_session2.pcap").values())
|
||||
messages_session3 = list(PCAPImporter.readFile(f"{PCAP_DIR}/target_src_v1_session3.pcap").values())
|
||||
|
||||
messages = messages_session1 + messages_session2
|
||||
|
||||
print("=" * 60)
|
||||
print("Step 1: Messages imported from PCAP")
|
||||
print("=" * 60)
|
||||
print(f"Session1: {len(messages_session1)} messages")
|
||||
print(f"Session2: {len(messages_session2)} messages")
|
||||
print(f"Session3: {len(messages_session3)} messages")
|
||||
for m in messages:
|
||||
print(m)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Split by '#' delimiter
|
||||
# ---------------------------------------------------------------------------
|
||||
symbol = Symbol(messages=messages)
|
||||
Format.splitDelimiter(symbol, String("#"))
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 2: After splitDelimiter('#')")
|
||||
print("=" * 60)
|
||||
print(symbol)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Cluster by key field (first field = command name)
|
||||
# ---------------------------------------------------------------------------
|
||||
symbols = Format.clusterByKeyField(symbol, symbol.fields[0])
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 3: Symbols after clusterByKeyField")
|
||||
print("=" * 60)
|
||||
print(f"Number of symbols: {len(symbols)}")
|
||||
for name in sorted(symbols.keys()):
|
||||
print(f" * {name}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Sequence alignment on payload field (field[2])
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 4: Sequence alignment on payload field")
|
||||
print("=" * 60)
|
||||
for name, sym in symbols.items():
|
||||
if len(sym.fields) >= 3:
|
||||
Format.splitAligned(sym.fields[2], doInternalSlick=True)
|
||||
print(f"\n[{name}]")
|
||||
print(sym)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Find and apply size relations
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 5: Find and apply size relations")
|
||||
print("=" * 60)
|
||||
for name, sym in symbols.items():
|
||||
rels = RelationFinder.findOnSymbol(sym)
|
||||
if rels:
|
||||
print(f"\n[{name}] Relations found:")
|
||||
for rel in rels:
|
||||
print(f" {rel['relation_type']}: '{rel['x_attribute']}' <-> '{rel['y_attribute']}'")
|
||||
# Apply first relation
|
||||
rels[0]["x_fields"][0].domain = Size(rels[0]["y_fields"], factor=1/8.0)
|
||||
|
||||
print("\n[CMDencrypt] structure after applying Size relation:")
|
||||
if "CMDencrypt" in symbols:
|
||||
print(symbols["CMDencrypt"]._str_debug())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Generate automata
|
||||
# ---------------------------------------------------------------------------
|
||||
sym_list = list(symbols.values())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 6a: Chained states automaton (session1)")
|
||||
print("=" * 60)
|
||||
session1 = Session(messages_session1)
|
||||
abstract1 = session1.abstract(sym_list)
|
||||
automata_chained = Automata.generateChainedStatesAutomata(abstract1, sym_list)
|
||||
print(automata_chained.generateDotCode())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 6b: One-state automaton (session1)")
|
||||
print("=" * 60)
|
||||
automata_one = Automata.generateOneStateAutomata(abstract1, sym_list)
|
||||
print(automata_one.generateDotCode())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 6c: PTA automaton (session1 + session3)")
|
||||
print("=" * 60)
|
||||
session3 = Session(messages_session3)
|
||||
abstract3 = session3.abstract(sym_list)
|
||||
automata_pta = Automata.generatePTAAutomata([abstract1, abstract3], sym_list)
|
||||
print(automata_pta.generateDotCode())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Done. To visualize dot output: pipe to 'dot -Tsvg -o out.svg'")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
174
test/run_toy_protocol_old.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Toy Protocol Inference - based on discover_features.rst tutorial
|
||||
Reconstructed from the documented message samples (no PCAP files needed).
|
||||
|
||||
Protocol structure:
|
||||
<CMD/RES name> '#' <4-byte little-endian length> [<data>]
|
||||
|
||||
Steps covered:
|
||||
1. Build raw messages from known session data
|
||||
2. Split by '#' delimiter
|
||||
3. Cluster by key field (command name)
|
||||
4. Sequence alignment on payload field
|
||||
5. Find and apply size relations
|
||||
6. Generate automata (chained / one-state / PTA)
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '/home/zjz/CCSDS_study/netzob-030/test/src')
|
||||
|
||||
from netzob.all import *
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Raw session messages (reconstructed from tutorial output)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_msg(raw: bytes) -> RawMessage:
|
||||
return RawMessage(data=raw)
|
||||
|
||||
session1_raw = [
|
||||
b"CMDidentify#\x07\x00\x00\x00Roberto",
|
||||
b"RESidentify#\x00\x00\x00\x00\x00\x00\x00\x00",
|
||||
b"CMDinfo#\x00\x00\x00\x00",
|
||||
b"RESinfo#\x00\x00\x00\x00\x04\x00\x00\x00info",
|
||||
b"CMDstats#\x00\x00\x00\x00",
|
||||
b"RESstats#\x00\x00\x00\x00\x05\x00\x00\x00stats",
|
||||
b"CMDauthentify#\n\x00\x00\x00aStrongPwd",
|
||||
b"RESauthentify#\x00\x00\x00\x00\x00\x00\x00\x00",
|
||||
b"CMDencrypt#\x06\x00\x00\x00abcdef",
|
||||
b"RESencrypt#\x00\x00\x00\x00\x06\x00\x00\x00$ !&'$",
|
||||
b"CMDdecrypt#\x06\x00\x00\x00$ !&'$",
|
||||
b"RESdecrypt#\x00\x00\x00\x00\x06\x00\x00\x00abcdef",
|
||||
b"CMDbye#\x00\x00\x00\x00",
|
||||
b"RESbye#\x00\x00\x00\x00\x00\x00\x00\x00",
|
||||
]
|
||||
|
||||
session2_raw = [
|
||||
b"CMDidentify#\x04\x00\x00\x00fred",
|
||||
b"RESidentify#\x00\x00\x00\x00\x00\x00\x00\x00",
|
||||
b"CMDinfo#\x00\x00\x00\x00",
|
||||
b"RESinfo#\x00\x00\x00\x00\x04\x00\x00\x00info",
|
||||
b"CMDstats#\x00\x00\x00\x00",
|
||||
b"RESstats#\x00\x00\x00\x00\x05\x00\x00\x00stats",
|
||||
b"CMDauthentify#\t\x00\x00\x00myPasswd!",
|
||||
b"RESauthentify#\x00\x00\x00\x00\x00\x00\x00\x00",
|
||||
b"CMDencrypt#\n\x00\x00\x00123456test",
|
||||
b"RESencrypt#\x00\x00\x00\x00\n\x00\x00\x00spqvwt6'16",
|
||||
b"CMDdecrypt#\n\x00\x00\x00spqvwt6'16",
|
||||
b"RESdecrypt#\x00\x00\x00\x00\n\x00\x00\x00123456test",
|
||||
b"CMDbye#\x00\x00\x00\x00",
|
||||
b"RESbye#\x00\x00\x00\x00\x00\x00\x00\x00",
|
||||
]
|
||||
|
||||
# session3: skip decrypt step (different path for PTA automata)
|
||||
session3_raw = [
|
||||
b"CMDidentify#\n\x00\x00\x00123456test",
|
||||
b"RESidentify#\x00\x00\x00\x00\x00\x00\x00\x00",
|
||||
b"CMDinfo#\x00\x00\x00\x00",
|
||||
b"RESinfo#\x00\x00\x00\x00\x04\x00\x00\x00info",
|
||||
b"CMDstats#\x00\x00\x00\x00",
|
||||
b"RESstats#\x00\x00\x00\x00\x05\x00\x00\x00stats",
|
||||
b"CMDauthentify#\n\x00\x00\x00123456test",
|
||||
b"RESauthentify#\x00\x00\x00\x00\x00\x00\x00\x00",
|
||||
b"CMDdecrypt#\x06\x00\x00\x00abcdef",
|
||||
b"RESdecrypt#\x00\x00\x00\x00\x06\x00\x00\x00abcdef",
|
||||
b"CMDbye#\x00\x00\x00\x00",
|
||||
b"RESbye#\x00\x00\x00\x00\x00\x00\x00\x00",
|
||||
]
|
||||
|
||||
messages_session1 = [make_msg(r) for r in session1_raw]
|
||||
messages_session2 = [make_msg(r) for r in session2_raw]
|
||||
messages_session3 = [make_msg(r) for r in session3_raw]
|
||||
|
||||
messages = messages_session1 + messages_session2
|
||||
|
||||
print("=" * 60)
|
||||
print("Step 1: Raw messages")
|
||||
print("=" * 60)
|
||||
for m in messages:
|
||||
print(repr(m.data))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Split by '#' delimiter
|
||||
# ---------------------------------------------------------------------------
|
||||
symbol = Symbol(messages=messages)
|
||||
Format.splitDelimiter(symbol, ASCII("#"))
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 2: After splitDelimiter('#')")
|
||||
print("=" * 60)
|
||||
print(symbol)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Cluster by key field (first field = command name)
|
||||
# ---------------------------------------------------------------------------
|
||||
symbols = Format.clusterByKeyField(symbol, symbol.fields[0])
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 3: Symbols after clusterByKeyField")
|
||||
print("=" * 60)
|
||||
print(f"Number of symbols: {len(symbols)}")
|
||||
for name in sorted(symbols.keys()):
|
||||
print(f" * {name}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Sequence alignment on payload field (field[2])
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 4: Sequence alignment on payload field")
|
||||
print("=" * 60)
|
||||
for name, sym in symbols.items():
|
||||
if len(sym.fields) >= 3:
|
||||
Format.splitAligned(sym.fields[2], doInternalSlick=True)
|
||||
print(f"\n[{name}]")
|
||||
print(sym)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Find and apply size relations
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 5: Find and apply size relations")
|
||||
print("=" * 60)
|
||||
for name, sym in symbols.items():
|
||||
rels = RelationFinder.findOnSymbol(sym)
|
||||
if rels:
|
||||
print(f"\n[{name}] Relations found:")
|
||||
for rel in rels:
|
||||
print(f" {rel['relation_type']}: '{rel['x_attribute']}' <-> '{rel['y_attribute']}'")
|
||||
# Apply first relation
|
||||
rels[0]["x_fields"][0].domain = Size(rels[0]["y_fields"], factor=1/8.0)
|
||||
|
||||
print("\n[CMDencrypt] structure after applying Size relation:")
|
||||
if "CMDencrypt" in symbols:
|
||||
print(symbols["CMDencrypt"]._str_debug())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Generate automata
|
||||
# ---------------------------------------------------------------------------
|
||||
sym_list = list(symbols.values())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 6a: Chained states automaton (session1)")
|
||||
print("=" * 60)
|
||||
session1 = Session(messages_session1)
|
||||
abstract1 = session1.abstract(sym_list)
|
||||
automata_chained = Automata.generateChainedStatesAutomata(abstract1, sym_list)
|
||||
print(automata_chained.generateDotCode())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 6b: One-state automaton (session1)")
|
||||
print("=" * 60)
|
||||
automata_one = Automata.generateOneStateAutomata(abstract1, sym_list)
|
||||
print(automata_one.generateDotCode())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 6c: PTA automaton (session1 + session3)")
|
||||
print("=" * 60)
|
||||
session3 = Session(messages_session3)
|
||||
abstract3 = session3.abstract(sym_list)
|
||||
automata_pta = Automata.generatePTAAutomata([abstract1, abstract3], sym_list)
|
||||
print(automata_pta.generateDotCode())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Done. To visualize dot output: pipe to 'dot -Tsvg -o out.svg'")
|
||||
print("=" * 60)
|
||||
41
test/run_usb_mouse_protocol.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import binascii
|
||||
import logging
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/home/zjz/CCSDS_study/netzob-030/test/src')
|
||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||
|
||||
from netzob.all import *
|
||||
|
||||
samples = [
|
||||
"00ff1f000000", "00fe1f000000", "00fe1f000000", "00fe1f000000",
|
||||
"00ff1f000000", "00ff1f000000", "00ff0f000000", "00fe2f000000",
|
||||
"00fe1f000000", "00ff0f000000", "000010000000", "00fe1f000000",
|
||||
"00ff1f000000", "00ff0f000000", "00ff1f000000", "000010000000",
|
||||
"00ff0f000000", "000010000000", "00ff1f000000", "00ff0f000000",
|
||||
"00ff1f000000", "00fe0f000000", "00ff1f000000", "00ff1f000000",
|
||||
"00ff0f000000", "00fe1f000000", "00fe2f000000", "00fe1f000000",
|
||||
"00fe1f000000", "00fe1f000000", "000010000000", "00ff0f000000",
|
||||
"000010000000", "00fe0f000000", "00ff1f000000", "00fe1f000000",
|
||||
"00ff1f000000", "00ff1f000000", "00ff0f000000", "000010000000",
|
||||
"00fe1f000000", "00ff2f000000", "00fe1f000000", "00fe1f000000",
|
||||
"00ff1f000000", "00ff1f000000", "00ff1f000000", "00ff1f000000",
|
||||
"00fe1f000000", "00fe2f000000",
|
||||
]
|
||||
|
||||
messages = [RawMessage(data=binascii.unhexlify(s)) for s in samples]
|
||||
symbol = Symbol(messages=messages)
|
||||
symbol.addEncodingFunction(TypeEncodingFunction(HexaString))
|
||||
|
||||
print("=== 原始消息 ===")
|
||||
print(symbol)
|
||||
|
||||
# 静态字段切分(保留相邻动态字段分离)
|
||||
Format.splitStatic(symbol, mergeAdjacentDynamicFields=False)
|
||||
|
||||
print("\n=== splitStatic 推理结果 ===")
|
||||
print(symbol)
|
||||
|
||||
print("\n=== 字段结构 ===")
|
||||
for i, field in enumerate(symbol.children):
|
||||
print(f"Field[{i}]: {field.name} domain={field.domain}")
|
||||