CCSDS_study project

This commit is contained in:
2026-05-05 21:54:35 +08:00
commit 9be41f9270
585 changed files with 91275 additions and 0 deletions

View 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 对应的时间戳。
理解这些变量后,整个脚本的结构就比较清楚了。

View 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 是 APIDvalue 是属于该 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()

View 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()

View 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()

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View 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种CRCCRC16、CRC16DNP、CRC16Kermit、CRC16SICK、CRC32、CRCCCITT
**`test_Hashes.py`** — 6种哈希MD5、SHA1、SHA2-224/256/384/512短消息+长消息)
**`test_Hmacs.py`** — 5种HMACHMAC-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的测试最为严格使用官方标准向量逐条验证。

View 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())

View 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
View 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 6cPTA 自动机
```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
View 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)

View 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)

View 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}")