pymisb 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pymisb/__init__.py +13 -0
- pymisb/common/__init__.py +15 -0
- pymisb/common/dji.py +432 -0
- pymisb/common/encoder.py +158 -0
- pymisb/common/ts.py +39 -0
- pymisb/constants.py +40 -0
- pymisb/demux/__init__.py +7 -0
- pymisb/demux/__main__.py +3 -0
- pymisb/demux/engine.py +206 -0
- pymisb/klvdata/__init__.py +13 -0
- pymisb/klvdata/common.py +137 -0
- pymisb/klvdata/element.py +53 -0
- pymisb/klvdata/elementparser.py +146 -0
- pymisb/klvdata/klvparser.py +64 -0
- pymisb/klvdata/misb0102.py +36 -0
- pymisb/klvdata/misb0601.py +674 -0
- pymisb/klvdata/misb0903.py +182 -0
- pymisb/klvdata/misbEG0104.py +23 -0
- pymisb/klvdata/setparser.py +128 -0
- pymisb/klvdata/streamparser.py +53 -0
- pymisb/mux/__init__.py +7 -0
- pymisb/mux/__main__.py +3 -0
- pymisb/mux/engine.py +425 -0
- pymisb/stream/__init__.py +7 -0
- pymisb/stream/__main__.py +3 -0
- pymisb/stream/engine.py +264 -0
- pymisb/vmti/__init__.py +7 -0
- pymisb/vmti/__main__.py +3 -0
- pymisb/vmti/engine.py +173 -0
- pymisb-1.0.0.dist-info/METADATA +384 -0
- pymisb-1.0.0.dist-info/RECORD +35 -0
- pymisb-1.0.0.dist-info/WHEEL +5 -0
- pymisb-1.0.0.dist-info/entry_points.txt +5 -0
- pymisb-1.0.0.dist-info/licenses/LICENSE +26 -0
- pymisb-1.0.0.dist-info/top_level.txt +1 -0
pymisb/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Author: Fran Raga <franka1986@gmail.com>
|
|
2
|
+
|
|
3
|
+
"""pymisb — MISB ST0601 / STANAG 4609 mux, demux & streaming library."""
|
|
4
|
+
|
|
5
|
+
from .constants import UAS_LS_KEY, ST0601_VERSION, HFOV_DEG, VFOV_DEG, EARTH_MEAN_RADIUS
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"UAS_LS_KEY",
|
|
9
|
+
"ST0601_VERSION",
|
|
10
|
+
"HFOV_DEG",
|
|
11
|
+
"VFOV_DEG",
|
|
12
|
+
"EARTH_MEAN_RADIUS",
|
|
13
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Author: Fran Raga <franka1986@gmail.com>
|
|
2
|
+
|
|
3
|
+
"""Shared utilities: ST0601 encoder, DJI parsers, and TS helpers."""
|
|
4
|
+
|
|
5
|
+
from .encoder import build_st0601_packet
|
|
6
|
+
from .dji import build_klv_packets, build_klv_packets_from_txt
|
|
7
|
+
from .ts import default_output, write_klv_stream
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"build_st0601_packet",
|
|
11
|
+
"build_klv_packets",
|
|
12
|
+
"build_klv_packets_from_txt",
|
|
13
|
+
"default_output",
|
|
14
|
+
"write_klv_stream",
|
|
15
|
+
]
|
pymisb/common/dji.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# Author: Fran Raga <franka1986@gmail.com>
|
|
2
|
+
|
|
3
|
+
"""DJI telemetry parsers — CSV and binary .txt flight records into KLV packets.
|
|
4
|
+
|
|
5
|
+
Parses DJI telemetry data from two sources:
|
|
6
|
+
- CSV files exported by DJI Assistant / CsvView
|
|
7
|
+
- Binary .txt flight records written by the drone firmware
|
|
8
|
+
|
|
9
|
+
Both produce ``(t_rel_seconds, st0601_packet_bytes)`` tuples ready for muxing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import csv
|
|
16
|
+
import struct
|
|
17
|
+
from math import tan, cos, sin, radians, degrees
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
from ..constants import TAN_HFOV_HALF, EARTH_MEAN_RADIUS
|
|
21
|
+
from .encoder import build_st0601_packet
|
|
22
|
+
|
|
23
|
+
# DJI CSV column -> (val_key, extra_key, normalize_angle)
|
|
24
|
+
_CSV_MAP = [
|
|
25
|
+
("OSD.yaw", "platform_heading", None, True),
|
|
26
|
+
("OSD.pitch", "platform_pitch", None, False),
|
|
27
|
+
("OSD.roll", "platform_roll", None, False),
|
|
28
|
+
("OSD.latitude", "sensor_lat", None, False),
|
|
29
|
+
("OSD.longitude", "sensor_lon", None, False),
|
|
30
|
+
("OSD.altitude [m]", "sensor_alt", None, False),
|
|
31
|
+
("GIMBAL.yaw", "sensor_rel_az", None, True),
|
|
32
|
+
("GIMBAL.pitch", "sensor_rel_el", "_gimbal_pitch", False),
|
|
33
|
+
("GIMBAL.roll", "sensor_rel_roll", None, True),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def _to_float(text: str):
|
|
40
|
+
"""Safely convert *text* to float, returning None on failure."""
|
|
41
|
+
try:
|
|
42
|
+
return float(text)
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_dji_time(text: str) -> datetime:
|
|
48
|
+
"""Parse a DJI timestamp string into a UTC datetime.
|
|
49
|
+
|
|
50
|
+
DJI uses the format ``YYYY/MM/DD HH:MM:SS.fff`` (sometimes without
|
|
51
|
+
milliseconds). Returns a timezone-aware datetime in UTC.
|
|
52
|
+
"""
|
|
53
|
+
for fmt in ("%Y/%m/%d %H:%M:%S.%f", "%Y/%m/%d %H:%M:%S"):
|
|
54
|
+
try:
|
|
55
|
+
return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
|
|
56
|
+
except ValueError:
|
|
57
|
+
continue
|
|
58
|
+
raise ValueError(f"Unrecognized date format: {text!r}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _compute_geometry(row_vals: dict) -> dict:
|
|
62
|
+
"""Compute sensor footprint geometry from the platform pose.
|
|
63
|
+
|
|
64
|
+
Calculates slant range, target width, and frame center ground position
|
|
65
|
+
from the sensor altitude, platform pitch, gimbal pitch, and heading.
|
|
66
|
+
This is an approximation (same approach as QGISFMV) — good enough to
|
|
67
|
+
draw the sensor footprint on a map.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
row_vals: Must contain ``sensor_lat``, ``sensor_lon``,
|
|
71
|
+
``sensor_alt``, ``platform_heading``. May contain
|
|
72
|
+
``platform_pitch`` and ``_gimbal_pitch``.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Dict with ``slant_range``, ``target_width``, ``fc_lat``,
|
|
76
|
+
``fc_lon``, ``fc_elev`` (or empty dict if inputs are missing).
|
|
77
|
+
"""
|
|
78
|
+
out: dict = {}
|
|
79
|
+
lat = row_vals.get("sensor_lat")
|
|
80
|
+
lon = row_vals.get("sensor_lon")
|
|
81
|
+
alt = row_vals.get("sensor_alt")
|
|
82
|
+
yaw = row_vals.get("platform_heading")
|
|
83
|
+
pitch = row_vals.get("platform_pitch", 0.0) or 0.0
|
|
84
|
+
gimbal_pitch = row_vals.get("_gimbal_pitch", 0.0) or 0.0
|
|
85
|
+
|
|
86
|
+
if None in (lat, lon, alt) or yaw is None:
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
lat = float(lat)
|
|
90
|
+
lon = float(lon)
|
|
91
|
+
alt = float(alt)
|
|
92
|
+
yaw = float(yaw)
|
|
93
|
+
|
|
94
|
+
depression = pitch + gimbal_pitch
|
|
95
|
+
angle = 180.0 + depression
|
|
96
|
+
cos_a = cos(radians(angle))
|
|
97
|
+
if abs(cos_a) < 0.0017:
|
|
98
|
+
cos_a = 0.0017 * (1 if cos_a >= 0 else -1)
|
|
99
|
+
slant = abs(alt / cos_a)
|
|
100
|
+
out["slant_range"] = slant
|
|
101
|
+
out["target_width"] = 2.0 * slant * TAN_HFOV_HALF
|
|
102
|
+
|
|
103
|
+
ground_angle = 90.0 + depression
|
|
104
|
+
tg_dist = alt * tan(radians(ground_angle))
|
|
105
|
+
dy = tg_dist * cos(radians(yaw))
|
|
106
|
+
dx = tg_dist * sin(radians(yaw))
|
|
107
|
+
fc_lat = lat + degrees(dy / EARTH_MEAN_RADIUS)
|
|
108
|
+
cos_lat = cos(radians(lat)) or 1e-6
|
|
109
|
+
fc_lon = lon + degrees(dx / EARTH_MEAN_RADIUS) / cos_lat
|
|
110
|
+
out["fc_lat"] = fc_lat
|
|
111
|
+
out["fc_lon"] = fc_lon
|
|
112
|
+
out["fc_elev"] = 0.0
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── CSV parser ────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def build_klv_packets(csv_path: str, only_recording: bool = True) -> list[tuple[float, bytes]]:
|
|
119
|
+
"""Parse a DJI telemetry CSV and return ST0601 KLV packets.
|
|
120
|
+
|
|
121
|
+
Reads the CSV row by row, extracts OSD and gimbal values, computes
|
|
122
|
+
the sensor footprint geometry, and encodes each row as an ST0601
|
|
123
|
+
packet with an absolute UNIX timestamp.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
csv_path: Path to the DJI telemetry CSV file.
|
|
127
|
+
only_recording: When True (default), only rows where
|
|
128
|
+
``CUSTOM.isVideo`` equals ``'Recording'`` are used.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
List of ``(t_rel_seconds, packet_bytes)`` tuples, where
|
|
132
|
+
``t_rel`` is seconds since the first recording row (used as
|
|
133
|
+
the PTS in the MPEG-TS mux).
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
FileNotFoundError: If *csv_path* does not exist.
|
|
137
|
+
RuntimeError: If no valid packets were generated.
|
|
138
|
+
"""
|
|
139
|
+
if not os.path.isfile(csv_path):
|
|
140
|
+
raise FileNotFoundError(csv_path)
|
|
141
|
+
|
|
142
|
+
packets: list[tuple[float, bytes]] = []
|
|
143
|
+
t0 = None
|
|
144
|
+
|
|
145
|
+
with open(csv_path, newline="", encoding="utf-8", errors="replace") as fh:
|
|
146
|
+
reader = csv.DictReader(fh)
|
|
147
|
+
for row in reader:
|
|
148
|
+
is_video = (row.get("CUSTOM.isVideo") or "").strip()
|
|
149
|
+
if only_recording and not is_video:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
time_str = (row.get("CUSTOM.updateTime") or "").strip()
|
|
153
|
+
if not time_str:
|
|
154
|
+
continue
|
|
155
|
+
t = _parse_dji_time(time_str)
|
|
156
|
+
if t0 is None:
|
|
157
|
+
t0 = t
|
|
158
|
+
t_rel = (t - t0).total_seconds()
|
|
159
|
+
if t_rel < 0:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
vals: dict = {"timestamp_us": int(t.timestamp() * 1_000_000)}
|
|
163
|
+
|
|
164
|
+
for csv_col, val_key, extra_key, norm_angle in _CSV_MAP:
|
|
165
|
+
v = _to_float(row.get(csv_col))
|
|
166
|
+
if v is not None:
|
|
167
|
+
if norm_angle:
|
|
168
|
+
v = v % 360.0
|
|
169
|
+
vals[val_key] = v
|
|
170
|
+
if extra_key:
|
|
171
|
+
vals[extra_key] = v
|
|
172
|
+
|
|
173
|
+
vals.update(_compute_geometry(vals))
|
|
174
|
+
vals.pop("_gimbal_pitch", None)
|
|
175
|
+
packets.append((t_rel, build_st0601_packet(vals)))
|
|
176
|
+
|
|
177
|
+
if not packets:
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
"No KLV packet was generated. Does the CSV have rows with "
|
|
180
|
+
"CUSTOM.isVideo = 'Recording'? Try --all-rows."
|
|
181
|
+
)
|
|
182
|
+
return packets
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── Binary .txt parser ────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
# CRC-64 lookup table (Jones polynomial) for XOR record decoding.
|
|
188
|
+
_CRC64_TABLE = [
|
|
189
|
+
0x0, 0x7ad870c830358979, 0xf5b0e190606b12f2, 0x8f689158505e9b8b,
|
|
190
|
+
0xc038e5739841b68f, 0xbae095bba8743ff6, 0x358804e3f82aa47d, 0x4f50742bc81f2d04,
|
|
191
|
+
0xab28ecb46814fe75, 0xd1f09c7c5821770c, 0x5e980d24087fec87, 0x24407dec384a65fe,
|
|
192
|
+
0x6b1009c7f05548fa, 0x11c8790fc060c183, 0x9ea0e857903e5a08, 0xe478989fa00bd371,
|
|
193
|
+
0x7d08ff3b88be6f81, 0x7d08ff3b88be6f8, 0x88b81eabe8d57d73, 0xf2606e63d8e0f40a,
|
|
194
|
+
0xbd301a4810ffd90e, 0xc7e86a8020ca5077, 0x4880fbd87094cbfc, 0x32588b1040a14285,
|
|
195
|
+
0xd620138fe0aa91f4, 0xacf86347d09f188d, 0x2390f21f80c18306, 0x594882d7b0f40a7f,
|
|
196
|
+
0x1618f6fc78eb277b, 0x6cc0863448deae02, 0xe3a8176c18803589, 0x997067a428b5bcf0,
|
|
197
|
+
0xfa11fe77117cdf02, 0x80c98ebf2149567b, 0xfa11fe77117cdf0, 0x75796f2f41224489,
|
|
198
|
+
0x3a291b04893d698d, 0x40f16bccb908e0f4, 0xcf99fa94e9567b7f, 0xb5418a5cd963f206,
|
|
199
|
+
0x513912c379682177, 0x2be1620b495da80e, 0xa489f35319033385, 0xde51839b2936bafc,
|
|
200
|
+
0x9101f7b0e12997f8, 0xebd98778d11c1e81, 0x64b116208142850a, 0x1e6966e8b1770c73,
|
|
201
|
+
0x8719014c99c2b083, 0xfdc17184a9f739fa, 0x72a9e0dcf9a9a271, 0x8719014c99c2b08,
|
|
202
|
+
0x4721e43f0183060c, 0x3df994f731b68f75, 0xb29105af61e814fe, 0xc849756751dd9d87,
|
|
203
|
+
0x2c31edf8f1d64ef6, 0x56e99d30c1e3c78f, 0xd9810c6891bd5c04, 0xa3597ca0a188d57d,
|
|
204
|
+
0xec09088b6997f879, 0x96d1784359a27100, 0x19b9e91b09fcea8b, 0x636199d339c963f2,
|
|
205
|
+
0xdf7adabd7a6e2d6f, 0xa5a2aa754a5ba416, 0x2aca3b2d1a053f9d, 0x50124be52a30b6e4,
|
|
206
|
+
0x1f423fcee22f9be0, 0x659a4f06d21a1299, 0xeaf2de5e82448912, 0x902aae96b271006b,
|
|
207
|
+
0x74523609127ad31a, 0xe8a46c1224f5a63, 0x81e2d7997211c1e8, 0xfb3aa75142244891,
|
|
208
|
+
0xb46ad37a8a3b6595, 0xceb2a3b2ba0eecec, 0x41da32eaea507767, 0x3b024222da65fe1e,
|
|
209
|
+
0xa2722586f2d042ee, 0xd8aa554ec2e5cb97, 0x57c2c41692bb501c, 0x2d1ab4dea28ed965,
|
|
210
|
+
0x624ac0f56a91f461, 0x1892b03d5aa47d18, 0x97fa21650afae693, 0xed2251ad3acf6fea,
|
|
211
|
+
0x95ac9329ac4bc9b, 0x7382b9faaaf135e2, 0xfcea28a2faafae69, 0x8632586aca9a2710,
|
|
212
|
+
0xc9622c4102850a14, 0xb3ba5c8932b0836d, 0x3cd2cdd162ee18e6, 0x460abd1952db919f,
|
|
213
|
+
0x256b24ca6b12f26d, 0x5fb354025b277b14, 0xd0dbc55a0b79e09f, 0xaa03b5923b4c69e6,
|
|
214
|
+
0xe553c1b9f35344e2, 0x9f8bb171c366cd9b, 0x10e3202993385610, 0x6a3b50e1a30ddf69,
|
|
215
|
+
0x8e43c87e03060c18, 0xf49bb8b633338561, 0x7bf329ee636d1eea, 0x12b592653589793,
|
|
216
|
+
0x4e7b2d0d9b47ba97, 0x34a35dc5ab7233ee, 0xbbcbcc9dfb2ca865, 0xc113bc55cb19211c,
|
|
217
|
+
0x5863dbf1e3ac9dec, 0x22bbab39d3991495, 0xadd33a6183c78f1e, 0xd70b4aa9b3f20667,
|
|
218
|
+
0x985b3e827bed2b63, 0xe2834e4a4bd8a21a, 0x6debdf121b863991, 0x1733afda2bb3b0e8,
|
|
219
|
+
0xf34b37458bb86399, 0x8993478dbb8deae0, 0x6fbd6d5ebd3716b, 0x7c23a61ddbe6f812,
|
|
220
|
+
0x3373d23613f9d516, 0x49aba2fe23cc5c6f, 0xc6c333a67392c7e4, 0xbc1b436e43a74e9d,
|
|
221
|
+
0x95ac9329ac4bc9b5, 0xef74e3e19c7e40cc, 0x601c72b9cc20db47, 0x1ac40271fc15523e,
|
|
222
|
+
0x5594765a340a7f3a, 0x2f4c0692043ff643, 0xa02497ca54616dc8, 0xdafce7026454e4b1,
|
|
223
|
+
0x3e847f9dc45f37c0, 0x445c0f55f46abeb9, 0xcb349e0da4342532, 0xb1eceec59401ac4b,
|
|
224
|
+
0xfebc9aee5c1e814f, 0x8464ea266c2b0836, 0xb0c7b7e3c7593bd, 0x71d40bb60c401ac4,
|
|
225
|
+
0xe8a46c1224f5a634, 0x927c1cda14c02f4d, 0x1d148d82449eb4c6, 0x67ccfd4a74ab3dbf,
|
|
226
|
+
0x289c8961bcb410bb, 0x5244f9a98c8199c2, 0xdd2c68f1dcdf0249, 0xa7f41839ecea8b30,
|
|
227
|
+
0x438c80a64ce15841, 0x3954f06e7cd4d138, 0xb63c61362c8a4ab3, 0xcce411fe1cbfc3ca,
|
|
228
|
+
0x83b465d5d4a0eece, 0xf96c151de49567b7, 0x76048445b4cbfc3c, 0xcdcf48d84fe7545,
|
|
229
|
+
0x6fbd6d5ebd3716b7, 0x15651d968d029fce, 0x9a0d8ccedd5c0445, 0xe0d5fc06ed698d3c,
|
|
230
|
+
0xaf85882d2576a038, 0xd55df8e515432941, 0x5a3569bd451db2ca, 0x20ed197575283bb3,
|
|
231
|
+
0xc49581ead523e8c2, 0xbe4df122e51661bb, 0x3125607ab548fa30, 0x4bfd10b2857d7349,
|
|
232
|
+
0x4ad64994d625e4d, 0x7e7514517d57d734, 0xf11d85092d094cbf, 0x8bc5f5c11d3cc5c6,
|
|
233
|
+
0x12b5926535897936, 0x686de2ad05bcf04f, 0xe70573f555e26bc4, 0x9ddd033d65d7e2bd,
|
|
234
|
+
0xd28d7716adc8cfb9, 0xa85507de9dfd46c0, 0x273d9686cda3dd4b, 0x5de5e64efd965432,
|
|
235
|
+
0xb99d7ed15d9d8743, 0xc3450e196da80e3a, 0x4c2d9f413df695b1, 0x36f5ef890dc31cc8,
|
|
236
|
+
0x79a59ba2c5dc31cc, 0x37deb6af5e9b8b5, 0x8c157a32a5b7233e, 0xf6cd0afa9582aa47,
|
|
237
|
+
0x4ad64994d625e4da, 0x300e395ce6106da3, 0xbf66a804b64ef628, 0xc5bed8cc867b7f51,
|
|
238
|
+
0x8aeeace74e645255, 0xf036dc2f7e51db2c, 0x7f5e4d772e0f40a7, 0x5863dbf1e3ac9de,
|
|
239
|
+
0xe1fea520be311aaf, 0x9b26d5e88e0493d6, 0x144e44b0de5a085d, 0x6e963478ee6f8124,
|
|
240
|
+
0x21c640532670ac20, 0x5b1e309b16452559, 0xd476a1c3461bbed2, 0xaeaed10b762e37ab,
|
|
241
|
+
0x37deb6af5e9b8b5b, 0x4d06c6676eae0222, 0xc26e573f3ef099a9, 0xb8b627f70ec510d0,
|
|
242
|
+
0xf7e653dcc6da3dd4, 0x8d3e2314f6efb4ad, 0x256b24ca6b12f26, 0x788ec2849684a65f,
|
|
243
|
+
0x9cf65a1b368f752e, 0xe62e2ad306bafc57, 0x6946bb8b56e467dc, 0x139ecb4366d1eea5,
|
|
244
|
+
0x5ccebf68aecec3a1, 0x2616cfa09efb4ad8, 0xa97e5ef8cea5d153, 0xd3a62e30fe90582a,
|
|
245
|
+
0xb0c7b7e3c7593bd8, 0xca1fc72bf76cb2a1, 0x45775673a732292a, 0x3faf26bb9707a053,
|
|
246
|
+
0x70ff52905f188d57, 0xa2722586f2d042e, 0x854fb3003f739fa5, 0xff97c3c80f4616dc,
|
|
247
|
+
0x1bef5b57af4dc5ad, 0x61372b9f9f784cd4, 0xee5fbac7cf26d75f, 0x9487ca0fff135e26,
|
|
248
|
+
0xdbd7be24370c7322, 0xa10fceec0739fa5b, 0x2e675fb4576761d0, 0x54bf2f7c6752e8a9,
|
|
249
|
+
0xcdcf48d84fe75459, 0xb71738107fd2dd20, 0x387fa9482f8c46ab, 0x42a7d9801fb9cfd2,
|
|
250
|
+
0xdf7adabd7a6e2d6, 0x772fdd63e7936baf, 0xf8474c3bb7cdf024, 0x829f3cf387f8795d,
|
|
251
|
+
0x66e7a46c27f3aa2c, 0x1c3fd4a417c62355, 0x935745fc4798b8de, 0xe98f353477ad31a7,
|
|
252
|
+
0xa6df411fbfb21ca3, 0xdc0731d78f8795da, 0x536fa08fdfd90e51, 0x29b7d047efec8728,
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
_MASK64 = 0xFFFFFFFFFFFFFFFF
|
|
256
|
+
_XOR_MAGIC = 0x123456789ABCDEF0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _crc64(seed: int, data: bytes) -> int:
|
|
260
|
+
"""Compute CRC-64 (Jones polynomial) used for DJI XOR record decoding.
|
|
261
|
+
|
|
262
|
+
The seed is combined with each byte of *data* via the precomputed
|
|
263
|
+
lookup table to produce a 64-bit checksum. This is an internal
|
|
264
|
+
helper for ``_xor_decode``.
|
|
265
|
+
"""
|
|
266
|
+
crc = seed & _MASK64
|
|
267
|
+
for byte in data:
|
|
268
|
+
index = (crc ^ byte) & 0xFF
|
|
269
|
+
crc = _CRC64_TABLE[index] ^ (crc >> 8)
|
|
270
|
+
return crc & _MASK64
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _xor_decode(data: bytes, record_type: int) -> bytes:
|
|
274
|
+
"""XOR-decode a DJI binary record payload (firmware v7+).
|
|
275
|
+
|
|
276
|
+
The first byte of *data* is the key seed. Combined with
|
|
277
|
+
*record_type*, it produces a pseudo-random XOR key via CRC-64.
|
|
278
|
+
Each payload byte (after the first) is XORed with the key to
|
|
279
|
+
recover the plaintext.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
data: Raw encrypted record payload (first byte = seed).
|
|
283
|
+
record_type: The DJI record type identifier (1 = OSD, 3 = gimbal).
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Decrypted payload bytes (without the seed byte).
|
|
287
|
+
"""
|
|
288
|
+
if len(data) < 2:
|
|
289
|
+
return data
|
|
290
|
+
first_byte = data[0]
|
|
291
|
+
seed = (first_byte + record_type) & 0xFF
|
|
292
|
+
key_input = ((_XOR_MAGIC * first_byte) & _MASK64).to_bytes(8, "little")
|
|
293
|
+
key = _crc64(seed, key_input).to_bytes(8, "little")
|
|
294
|
+
out = bytearray(len(data) - 1)
|
|
295
|
+
for i in range(len(out)):
|
|
296
|
+
out[i] = data[i + 1] ^ key[i % 8]
|
|
297
|
+
return bytes(out)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _parse_dji_txt(txt_path: str):
|
|
301
|
+
"""Parse a DJI binary .txt flight record.
|
|
302
|
+
|
|
303
|
+
Reads the binary file, extracts OSD records (lat, lon, alt, pitch,
|
|
304
|
+
roll, yaw) and gimbal records (pitch, roll, yaw). Supports firmware
|
|
305
|
+
format versions 1-12 (XOR-encoded). Versions 13-14 (AES-encrypted)
|
|
306
|
+
are not supported.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
txt_path: Path to the DJI .txt flight record.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Tuple of ``(osd_records, gimbal_records)`` where each OSD record
|
|
313
|
+
is ``(lat, lon, alt, pitch, roll, yaw)`` and each gimbal record
|
|
314
|
+
is ``(pitch, roll, yaw)``.
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
RuntimeError: If the file is too small or corrupt.
|
|
318
|
+
"""
|
|
319
|
+
with open(txt_path, "rb") as fh:
|
|
320
|
+
body = fh.read()
|
|
321
|
+
|
|
322
|
+
if len(body) < 12:
|
|
323
|
+
raise RuntimeError(
|
|
324
|
+
f"DJI .txt flight record is too small ({len(body)} bytes). "
|
|
325
|
+
"The file may be corrupt or truncated."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
header, detail_size, version = struct.unpack_from("<Qhb", body, 0)
|
|
329
|
+
headsize = 100 if version >= 6 else 12
|
|
330
|
+
|
|
331
|
+
if version >= 12:
|
|
332
|
+
record_start = headsize + detail_size
|
|
333
|
+
record_end = header
|
|
334
|
+
else:
|
|
335
|
+
record_start = headsize
|
|
336
|
+
record_end = header
|
|
337
|
+
|
|
338
|
+
record_area = body[record_start:record_end + 1]
|
|
339
|
+
osd_records: list[tuple] = []
|
|
340
|
+
gimbal_records: list[tuple] = []
|
|
341
|
+
|
|
342
|
+
i = 0
|
|
343
|
+
while i < len(record_area) - 2:
|
|
344
|
+
record_type = record_area[i]
|
|
345
|
+
record_size = record_area[i + 1]
|
|
346
|
+
payload = record_area[i + 2:i + 2 + record_size]
|
|
347
|
+
end_marker = record_area[i + 2 + record_size] if i + 2 + record_size < len(record_area) else 0
|
|
348
|
+
i += record_size + 3
|
|
349
|
+
|
|
350
|
+
if end_marker != 0xFF:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
if version >= 7:
|
|
354
|
+
payload = _xor_decode(payload, record_type)
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
if record_type == 1 and len(payload) >= 30:
|
|
358
|
+
lon_rad, lat_rad = struct.unpack_from("<dd", payload, 0)
|
|
359
|
+
height = struct.unpack_from("<h", payload, 16)[0]
|
|
360
|
+
pitch_r = struct.unpack_from("<h", payload, 22)[0]
|
|
361
|
+
roll_r = struct.unpack_from("<h", payload, 24)[0]
|
|
362
|
+
yaw_r = struct.unpack_from("<h", payload, 26)[0]
|
|
363
|
+
osd_records.append((
|
|
364
|
+
degrees(lat_rad), degrees(lon_rad),
|
|
365
|
+
height * 0.1, pitch_r * 0.1, roll_r * 0.1, yaw_r * 0.1,
|
|
366
|
+
))
|
|
367
|
+
elif record_type == 3 and len(payload) >= 6:
|
|
368
|
+
g_pitch, g_roll, g_yaw = struct.unpack_from("<hhh", payload, 0)
|
|
369
|
+
gimbal_records.append((g_pitch * 0.1, g_roll * 0.1, g_yaw * 0.1))
|
|
370
|
+
except (struct.error, IndexError):
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
return osd_records, gimbal_records
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def build_klv_packets_from_txt(txt_path: str) -> list[tuple[float, bytes]]:
|
|
377
|
+
"""Parse a DJI binary .txt flight record and return ST0601 KLV packets.
|
|
378
|
+
|
|
379
|
+
Reads the binary file directly (no CSV conversion needed), extracts
|
|
380
|
+
OSD and gimbal records, computes geometry, and encodes each sample
|
|
381
|
+
as an ST0601 packet.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
txt_path: Path to the DJI .txt flight record.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
List of ``(t_rel_seconds, packet_bytes)`` tuples at ~10 Hz.
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
FileNotFoundError: If *txt_path* does not exist.
|
|
391
|
+
RuntimeError: If no OSD records are found (may be AES-encrypted).
|
|
392
|
+
"""
|
|
393
|
+
if not os.path.isfile(txt_path):
|
|
394
|
+
raise FileNotFoundError(txt_path)
|
|
395
|
+
|
|
396
|
+
osd_records, gimbal_records = _parse_dji_txt(txt_path)
|
|
397
|
+
|
|
398
|
+
if not osd_records:
|
|
399
|
+
raise RuntimeError(
|
|
400
|
+
"No OSD records found in the DJI .txt flight record. "
|
|
401
|
+
"The file may use a format version (v13-14) that requires "
|
|
402
|
+
"AES decryption (not supported)."
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
packets: list[tuple[float, bytes]] = []
|
|
406
|
+
for idx, (lat, lon, alt, pitch, roll, yaw) in enumerate(osd_records):
|
|
407
|
+
t_rel = idx * 0.1 # ~10 Hz OSD sample rate
|
|
408
|
+
|
|
409
|
+
vals: dict = {
|
|
410
|
+
"timestamp_us": int(t_rel * 1_000_000),
|
|
411
|
+
"platform_heading": yaw % 360.0,
|
|
412
|
+
"platform_pitch": pitch,
|
|
413
|
+
"platform_roll": roll,
|
|
414
|
+
"sensor_lat": lat,
|
|
415
|
+
"sensor_lon": lon,
|
|
416
|
+
"sensor_alt": alt,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if idx < len(gimbal_records):
|
|
420
|
+
gp, gr, gga = gimbal_records[idx]
|
|
421
|
+
vals["sensor_rel_az"] = gga % 360.0
|
|
422
|
+
vals["sensor_rel_el"] = gp
|
|
423
|
+
vals["sensor_rel_roll"] = gr % 360.0
|
|
424
|
+
vals["_gimbal_pitch"] = gp
|
|
425
|
+
|
|
426
|
+
vals.update(_compute_geometry(vals))
|
|
427
|
+
vals.pop("_gimbal_pitch", None)
|
|
428
|
+
packets.append((t_rel, build_st0601_packet(vals)))
|
|
429
|
+
|
|
430
|
+
if not packets:
|
|
431
|
+
raise RuntimeError("No KLV packets generated from the .txt flight record.")
|
|
432
|
+
return packets
|
pymisb/common/encoder.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Author: Fran Raga <franka1986@gmail.com>
|
|
2
|
+
|
|
3
|
+
"""MISB ST0601 KLV encoder — builds complete packets from value dicts.
|
|
4
|
+
|
|
5
|
+
Self-contained encoder that produces the 16-byte universal key, BER length,
|
|
6
|
+
tagged elements, and BCC-16 checksum. No external dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import struct
|
|
12
|
+
|
|
13
|
+
from ..constants import HFOV_DEG, VFOV_DEG, ST0601_VERSION, UAS_LS_KEY
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Low-level encoding helpers ────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
def _clamp(value: float, lo: float, hi: float) -> float:
|
|
19
|
+
"""Restrict *value* to the range [lo, hi]."""
|
|
20
|
+
return max(lo, min(hi, value))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def encode_unsigned(value: float, vmin: float, vmax: float, nbytes: int) -> int:
|
|
24
|
+
"""Map a float in [vmin, vmax] to an unsigned integer of *nbytes*.
|
|
25
|
+
|
|
26
|
+
The value is clamped to the valid range first, then linearly mapped
|
|
27
|
+
to [0, 2^(8*nbytes)-1]. Used to encode MISB elements like heading
|
|
28
|
+
(0..360 -> uint16) or altitude (-900..19000 -> uint16).
|
|
29
|
+
"""
|
|
30
|
+
value = _clamp(value, vmin, vmax)
|
|
31
|
+
full = (1 << (8 * nbytes)) - 1
|
|
32
|
+
return int(round((value - vmin) / (vmax - vmin) * full))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def encode_signed(value: float, vabs: float, nbytes: int) -> int:
|
|
36
|
+
"""Map a float in [-vabs, vabs] to a signed integer of *nbytes*.
|
|
37
|
+
|
|
38
|
+
The value is clamped, then linearly mapped to the two's complement
|
|
39
|
+
range. Used for signed MISB elements like pitch (-20..20 -> int16)
|
|
40
|
+
or latitude (-90..90 -> int32).
|
|
41
|
+
"""
|
|
42
|
+
value = _clamp(value, -vabs, vabs)
|
|
43
|
+
half = (1 << (8 * nbytes - 1)) - 1
|
|
44
|
+
return int(round(value / vabs * half))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_tlv(tag: int, value: bytes) -> bytes:
|
|
48
|
+
"""Build a Tag-Length-Value triplet for a local set element.
|
|
49
|
+
|
|
50
|
+
Uses a 1-byte tag and 1-byte length, as defined by MISB ST0601
|
|
51
|
+
for elements inside the UAS Datalink Local Set.
|
|
52
|
+
"""
|
|
53
|
+
return bytes([tag, len(value)]) + value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ber_encode_length(n: int) -> bytes:
|
|
57
|
+
"""Encode an integer length in BER (Basic Encoding Rules).
|
|
58
|
+
|
|
59
|
+
Short form (1 byte) for values < 128; long form (1+N bytes) for
|
|
60
|
+
larger values, as required by SMPTE 336 / MISB ST0601.
|
|
61
|
+
"""
|
|
62
|
+
if n < 128:
|
|
63
|
+
return bytes([n])
|
|
64
|
+
out = bytearray()
|
|
65
|
+
while n > 0:
|
|
66
|
+
out.append(n & 0xFF)
|
|
67
|
+
n >>= 8
|
|
68
|
+
out.reverse()
|
|
69
|
+
return bytes([0x80 | len(out)]) + bytes(out)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def bcc_16(data: bytes) -> int:
|
|
73
|
+
"""Compute the ST0601 16-bit Block Check Code (BCC-16).
|
|
74
|
+
|
|
75
|
+
Sums bytes weighted by even/odd position within the data.
|
|
76
|
+
The result is used as the checksum appended to every ST0601 packet.
|
|
77
|
+
"""
|
|
78
|
+
s = 0
|
|
79
|
+
for i, b in enumerate(data):
|
|
80
|
+
s += b << (8 * ((i + 1) % 2))
|
|
81
|
+
return s & 0xFFFF
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Main encoder ──────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def build_st0601_packet(values: dict) -> bytes:
|
|
87
|
+
"""Build a complete MISB ST0601 packet from a dict of quantities.
|
|
88
|
+
|
|
89
|
+
Constructs the full binary packet: 16-byte universal key + BER length
|
|
90
|
+
+ encoded TLV elements + 2-byte BCC-16 checksum. This is the main
|
|
91
|
+
entry point for creating KLV packets that can be muxed into a TS stream.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
values: Dict with keys like ``timestamp_us``, ``sensor_lat``,
|
|
95
|
+
``platform_heading``, etc. Missing optional keys are skipped.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The raw bytes of the complete ST0601 packet.
|
|
99
|
+
"""
|
|
100
|
+
parts: list[bytes] = []
|
|
101
|
+
|
|
102
|
+
def add_tlv(tag: int, val: bytes):
|
|
103
|
+
parts.append(build_tlv(tag, val))
|
|
104
|
+
|
|
105
|
+
# Tag 2 - Precision Time Stamp (UNIX UTC microseconds, uint64)
|
|
106
|
+
if "timestamp_us" in values:
|
|
107
|
+
add_tlv(2, struct.pack(">Q", int(values["timestamp_us"])))
|
|
108
|
+
|
|
109
|
+
# Tag 65 - UAS LS Version Number
|
|
110
|
+
add_tlv(65, bytes([ST0601_VERSION]))
|
|
111
|
+
|
|
112
|
+
# Tags 5-7 - Platform attitude
|
|
113
|
+
if "platform_heading" in values:
|
|
114
|
+
add_tlv(5, struct.pack(">H", encode_unsigned(values["platform_heading"], 0, 360, 2)))
|
|
115
|
+
if "platform_pitch" in values:
|
|
116
|
+
add_tlv(6, struct.pack(">h", encode_signed(values["platform_pitch"], 20, 2)))
|
|
117
|
+
if "platform_roll" in values:
|
|
118
|
+
add_tlv(7, struct.pack(">h", encode_signed(values["platform_roll"], 50, 2)))
|
|
119
|
+
|
|
120
|
+
# Tags 13-15 - Sensor position
|
|
121
|
+
if "sensor_lat" in values:
|
|
122
|
+
add_tlv(13, struct.pack(">i", encode_signed(values["sensor_lat"], 90, 4)))
|
|
123
|
+
if "sensor_lon" in values:
|
|
124
|
+
add_tlv(14, struct.pack(">i", encode_signed(values["sensor_lon"], 180, 4)))
|
|
125
|
+
if "sensor_alt" in values:
|
|
126
|
+
add_tlv(15, struct.pack(">H", encode_unsigned(values["sensor_alt"], -900, 19000, 2)))
|
|
127
|
+
|
|
128
|
+
# Tags 16-17 - Sensor FOV
|
|
129
|
+
add_tlv(16, struct.pack(">H", encode_unsigned(HFOV_DEG, 0, 180, 2)))
|
|
130
|
+
add_tlv(17, struct.pack(">H", encode_unsigned(VFOV_DEG, 0, 180, 2)))
|
|
131
|
+
|
|
132
|
+
# Tags 18-20 - Sensor relative angles
|
|
133
|
+
if "sensor_rel_az" in values:
|
|
134
|
+
add_tlv(18, struct.pack(">I", encode_unsigned(values["sensor_rel_az"], 0, 360, 4)))
|
|
135
|
+
if "sensor_rel_el" in values:
|
|
136
|
+
add_tlv(19, struct.pack(">i", encode_signed(values["sensor_rel_el"], 180, 4)))
|
|
137
|
+
if "sensor_rel_roll" in values:
|
|
138
|
+
add_tlv(20, struct.pack(">I", encode_unsigned(values["sensor_rel_roll"], 0, 360, 4)))
|
|
139
|
+
|
|
140
|
+
# Tags 21-22 - Slant range and target width
|
|
141
|
+
if "slant_range" in values:
|
|
142
|
+
add_tlv(21, struct.pack(">I", encode_unsigned(values["slant_range"], 0, 5_000_000, 4)))
|
|
143
|
+
if "target_width" in values:
|
|
144
|
+
add_tlv(22, struct.pack(">H", encode_unsigned(values["target_width"], 0, 10_000, 2)))
|
|
145
|
+
|
|
146
|
+
# Tags 23-25 - Frame center
|
|
147
|
+
if "fc_lat" in values:
|
|
148
|
+
add_tlv(23, struct.pack(">i", encode_signed(values["fc_lat"], 90, 4)))
|
|
149
|
+
if "fc_lon" in values:
|
|
150
|
+
add_tlv(24, struct.pack(">i", encode_signed(values["fc_lon"], 180, 4)))
|
|
151
|
+
if "fc_elev" in values:
|
|
152
|
+
add_tlv(25, struct.pack(">H", encode_unsigned(values["fc_elev"], -900, 19000, 2)))
|
|
153
|
+
|
|
154
|
+
# Assemble: key + BER length + payload + checksum TLV + BCC-16
|
|
155
|
+
payload = b"".join(parts)
|
|
156
|
+
total_len = len(payload) + 4 # + checksum TLV (tag 1, len 2, value 2)
|
|
157
|
+
pre_checksum = UAS_LS_KEY + ber_encode_length(total_len) + payload + b"\x01\x02"
|
|
158
|
+
return pre_checksum + struct.pack(">H", bcc_16(pre_checksum))
|
pymisb/common/ts.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Author: Fran Raga <franka1986@gmail.com>
|
|
2
|
+
|
|
3
|
+
"""TS output helpers — path defaults and KLV stream writing."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from ..constants import OUT_SUFFIX
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def default_output(video_path: str) -> str:
|
|
11
|
+
"""Build a default output path next to the input video.
|
|
12
|
+
|
|
13
|
+
Given ``video.mp4``, returns ``./video_MISB.ts`` in the same directory.
|
|
14
|
+
If the path has no directory component, ``./`` is used.
|
|
15
|
+
"""
|
|
16
|
+
base = os.path.splitext(os.path.basename(video_path))[0]
|
|
17
|
+
return os.path.join(os.path.dirname(video_path) or ".", base + OUT_SUFFIX + ".ts")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_klv_stream(packets: list[tuple[float, bytes]], out_path: str) -> str:
|
|
21
|
+
"""Write KLV packets as a raw binary elementary stream.
|
|
22
|
+
|
|
23
|
+
Concatenates the raw bytes of each ST0601 packet (without any TS
|
|
24
|
+
framing) into a single .klv file. Useful for debugging or for
|
|
25
|
+
feeding into external TS muxers.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
packets: List of ``(t_rel_seconds, packet_bytes)`` tuples.
|
|
29
|
+
out_path: Destination file path. Parent directories are created
|
|
30
|
+
if they don't exist.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The *out_path* that was written.
|
|
34
|
+
"""
|
|
35
|
+
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
|
36
|
+
with open(out_path, "wb") as fh:
|
|
37
|
+
for _, pkt in packets:
|
|
38
|
+
fh.write(pkt)
|
|
39
|
+
return out_path
|