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 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
@@ -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