blob: 0f725fd17be24054f53b626e02d31ac97eee45c7 [file] [log] [blame]
James Kuszmaulcf324122023-01-14 14:07:17 -08001#! /usr/bin/env python3
2# Copyright (c) FIRST and other WPILib contributors.
3# Open Source Software; you can modify and/or share it under the terms of
4# the WPILib BSD license file in the root directory of this project.
5
6import array
7import struct
James Kuszmaulb13e13f2023-11-22 20:44:04 -08008import msgpack
James Kuszmaulcf324122023-01-14 14:07:17 -08009from typing import List, SupportsBytes
10
11__all__ = ["StartRecordData", "MetadataRecordData", "DataLogRecord", "DataLogReader"]
12
13floatStruct = struct.Struct("<f")
14doubleStruct = struct.Struct("<d")
15
16kControlStart = 0
17kControlFinish = 1
18kControlSetMetadata = 2
19
20
21class StartRecordData:
22 """Data contained in a start control record as created by DataLog.start() when
23 writing the log. This can be read by calling DataLogRecord.getStartData().
24
25 entry: Entry ID; this will be used for this entry in future records.
26 name: Entry name.
27 type: Type of the stored data for this entry, as a string, e.g. "double".
28 metadata: Initial metadata.
29 """
30
31 def __init__(self, entry: int, name: str, type: str, metadata: str):
32 self.entry = entry
33 self.name = name
34 self.type = type
35 self.metadata = metadata
36
37
38class MetadataRecordData:
39 """Data contained in a set metadata control record as created by
40 DataLog.setMetadata(). This can be read by calling
41 DataLogRecord.getSetMetadataData().
42
43 entry: Entry ID.
44 metadata: New metadata for the entry.
45 """
46
47 def __init__(self, entry: int, metadata: str):
48 self.entry = entry
49 self.metadata = metadata
50
51
52class DataLogRecord:
53 """A record in the data log. May represent either a control record
54 (entry == 0) or a data record."""
55
56 def __init__(self, entry: int, timestamp: int, data: SupportsBytes):
57 self.entry = entry
58 self.timestamp = timestamp
59 self.data = data
60
61 def isControl(self) -> bool:
62 return self.entry == 0
63
64 def _getControlType(self) -> int:
65 return self.data[0]
66
67 def isStart(self) -> bool:
68 return (
69 self.entry == 0
70 and len(self.data) >= 17
71 and self._getControlType() == kControlStart
72 )
73
74 def isFinish(self) -> bool:
75 return (
76 self.entry == 0
77 and len(self.data) == 5
78 and self._getControlType() == kControlFinish
79 )
80
81 def isSetMetadata(self) -> bool:
82 return (
83 self.entry == 0
84 and len(self.data) >= 9
85 and self._getControlType() == kControlSetMetadata
86 )
87
88 def getStartData(self) -> StartRecordData:
89 if not self.isStart():
90 raise TypeError("not a start record")
91 entry = int.from_bytes(self.data[1:5], byteorder="little", signed=False)
92 name, pos = self._readInnerString(5)
93 type, pos = self._readInnerString(pos)
94 metadata = self._readInnerString(pos)[0]
95 return StartRecordData(entry, name, type, metadata)
96
97 def getFinishEntry(self) -> int:
98 if not self.isFinish():
99 raise TypeError("not a finish record")
100 return int.from_bytes(self.data[1:5], byteorder="little", signed=False)
101
102 def getSetMetadataData(self) -> MetadataRecordData:
103 if not self.isSetMetadata():
104 raise TypeError("not a finish record")
105 entry = int.from_bytes(self.data[1:5], byteorder="little", signed=False)
106 metadata = self._readInnerString(5)[0]
107 return MetadataRecordData(entry, metadata)
108
109 def getBoolean(self) -> bool:
110 if len(self.data) != 1:
111 raise TypeError("not a boolean")
112 return self.data[0] != 0
113
114 def getInteger(self) -> int:
115 if len(self.data) != 8:
116 raise TypeError("not an integer")
117 return int.from_bytes(self.data, byteorder="little", signed=True)
118
119 def getFloat(self) -> float:
120 if len(self.data) != 4:
121 raise TypeError("not a float")
122 return floatStruct.unpack(self.data)[0]
123
124 def getDouble(self) -> float:
125 if len(self.data) != 8:
126 raise TypeError("not a double")
127 return doubleStruct.unpack(self.data)[0]
128
129 def getString(self) -> str:
130 return str(self.data, encoding="utf-8")
131
James Kuszmaulb13e13f2023-11-22 20:44:04 -0800132 def getMsgPack(self):
133 return msgpack.unpackb(self.data)
134
James Kuszmaulcf324122023-01-14 14:07:17 -0800135 def getBooleanArray(self) -> List[bool]:
136 return [x != 0 for x in self.data]
137
138 def getIntegerArray(self) -> array.array:
139 if (len(self.data) % 8) != 0:
140 raise TypeError("not an integer array")
141 arr = array.array("l")
142 arr.frombytes(self.data)
143 return arr
144
145 def getFloatArray(self) -> array.array:
146 if (len(self.data) % 4) != 0:
147 raise TypeError("not a float array")
148 arr = array.array("f")
149 arr.frombytes(self.data)
150 return arr
151
152 def getDoubleArray(self) -> array.array:
153 if (len(self.data) % 8) != 0:
154 raise TypeError("not a double array")
155 arr = array.array("d")
156 arr.frombytes(self.data)
157 return arr
158
159 def getStringArray(self) -> List[str]:
160 size = int.from_bytes(self.data[:4], byteorder="little", signed=False)
161 if size > ((len(self.data) - 4) / 4):
162 raise TypeError("not a string array")
163 arr = []
164 pos = 4
165 for _ in range(size):
166 val, pos = self._readInnerString(pos)
167 arr.append(val)
168 return arr
169
170 def _readInnerString(self, pos: int) -> tuple[str, int]:
171 size = int.from_bytes(
172 self.data[pos : pos + 4], byteorder="little", signed=False
173 )
174 end = pos + 4 + size
175 if end > len(self.data):
176 raise TypeError("invalid string size")
177 return str(self.data[pos + 4 : end], encoding="utf-8"), end
178
179
180class DataLogIterator:
181 """DataLogReader iterator."""
182
183 def __init__(self, buf: SupportsBytes, pos: int):
184 self.buf = buf
185 self.pos = pos
186
187 def __iter__(self):
188 return self
189
190 def _readVarInt(self, pos: int, len: int) -> int:
191 val = 0
192 for i in range(len):
193 val |= self.buf[pos + i] << (i * 8)
194 return val
195
196 def __next__(self) -> DataLogRecord:
197 if len(self.buf) < (self.pos + 4):
198 raise StopIteration
199 entryLen = (self.buf[self.pos] & 0x3) + 1
200 sizeLen = ((self.buf[self.pos] >> 2) & 0x3) + 1
201 timestampLen = ((self.buf[self.pos] >> 4) & 0x7) + 1
202 headerLen = 1 + entryLen + sizeLen + timestampLen
203 if len(self.buf) < (self.pos + headerLen):
204 raise StopIteration
205 entry = self._readVarInt(self.pos + 1, entryLen)
206 size = self._readVarInt(self.pos + 1 + entryLen, sizeLen)
207 timestamp = self._readVarInt(self.pos + 1 + entryLen + sizeLen, timestampLen)
208 if len(self.buf) < (self.pos + headerLen + size):
209 raise StopIteration
210 record = DataLogRecord(
211 entry,
212 timestamp,
213 self.buf[self.pos + headerLen : self.pos + headerLen + size],
214 )
215 self.pos += headerLen + size
216 return record
217
218
219class DataLogReader:
220 """Data log reader (reads logs written by the DataLog class)."""
221
222 def __init__(self, buf: SupportsBytes):
223 self.buf = buf
224
225 def __bool__(self):
226 return self.isValid()
227
228 def isValid(self) -> bool:
229 """Returns true if the data log is valid (e.g. has a valid header)."""
230 return (
231 len(self.buf) >= 12
232 and self.buf[:6] == b"WPILOG"
233 and self.getVersion() >= 0x0100
234 )
235
236 def getVersion(self) -> int:
237 """Gets the data log version. Returns 0 if data log is invalid.
238
239 @return Version number; most significant byte is major, least significant is
240 minor (so version 1.0 will be 0x0100)"""
241 if len(self.buf) < 12:
242 return 0
243 return int.from_bytes(self.buf[6:8], byteorder="little", signed=False)
244
245 def getExtraHeader(self) -> str:
246 """Gets the extra header data.
247
248 @return Extra header data
249 """
250 if len(self.buf) < 12:
251 return ""
252 size = int.from_bytes(self.buf[8:12], byteorder="little", signed=False)
253 return str(self.buf[12 : 12 + size], encoding="utf-8")
254
255 def __iter__(self) -> DataLogIterator:
256 extraHeaderSize = int.from_bytes(
257 self.buf[8:12], byteorder="little", signed=False
258 )
259 return DataLogIterator(self.buf, 12 + extraHeaderSize)
260
261
262if __name__ == "__main__":
263 from datetime import datetime
264 import mmap
265 import sys
266
267 if len(sys.argv) != 2:
268 print("Usage: datalog.py <file>", file=sys.stderr)
269 sys.exit(1)
270
271 with open(sys.argv[1], "r") as f:
272 mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
273 reader = DataLogReader(mm)
274 if not reader:
275 print("not a log file", file=sys.stderr)
276 sys.exit(1)
277
278 entries = {}
279 for record in reader:
280 timestamp = record.timestamp / 1000000
281 if record.isStart():
282 try:
283 data = record.getStartData()
284 print(
285 f"Start({data.entry}, name='{data.name}', type='{data.type}', metadata='{data.metadata}') [{timestamp}]"
286 )
287 if data.entry in entries:
288 print("...DUPLICATE entry ID, overriding")
289 entries[data.entry] = data
290 except TypeError as e:
291 print("Start(INVALID)")
292 elif record.isFinish():
293 try:
294 entry = record.getFinishEntry()
295 print(f"Finish({entry}) [{timestamp}]")
296 if entry not in entries:
297 print("...ID not found")
298 else:
299 del entries[entry]
300 except TypeError as e:
301 print("Finish(INVALID)")
302 elif record.isSetMetadata():
303 try:
304 data = record.getSetMetadataData()
305 print(f"SetMetadata({data.entry}, '{data.metadata}') [{timestamp}]")
306 if data.entry not in entries:
307 print("...ID not found")
308 except TypeError as e:
309 print("SetMetadata(INVALID)")
310 elif record.isControl():
311 print("Unrecognized control record")
312 else:
313 print(f"Data({record.entry}, size={len(record.data)}) ", end="")
314 entry = entries.get(record.entry)
315 if entry is None:
316 print("<ID not found>")
317 continue
318 print(f"<name='{entry.name}', type='{entry.type}'> [{timestamp}]")
319
320 try:
321 # handle systemTime specially
322 if entry.name == "systemTime" and entry.type == "int64":
323 dt = datetime.fromtimestamp(record.getInteger() / 1000000)
324 print(" {:%Y-%m-%d %H:%M:%S.%f}".format(dt))
325 continue
326
327 if entry.type == "double":
328 print(f" {record.getDouble()}")
329 elif entry.type == "int64":
330 print(f" {record.getInteger()}")
331 elif entry.type in ("string", "json"):
332 print(f" '{record.getString()}'")
James Kuszmaulb13e13f2023-11-22 20:44:04 -0800333 elif entry.type == "msgpack":
334 print(f" '{record.getMsgPack()}'")
James Kuszmaulcf324122023-01-14 14:07:17 -0800335 elif entry.type == "boolean":
336 print(f" {record.getBoolean()}")
337 elif entry.type == "boolean[]":
338 arr = record.getBooleanArray()
339 print(f" {arr}")
340 elif entry.type == "double[]":
341 arr = record.getDoubleArray()
342 print(f" {arr}")
343 elif entry.type == "float[]":
344 arr = record.getFloatArray()
345 print(f" {arr}")
346 elif entry.type == "int64[]":
347 arr = record.getIntegerArray()
348 print(f" {arr}")
349 elif entry.type == "string[]":
350 arr = record.getStringArray()
351 print(f" {arr}")
352 except TypeError as e:
353 print(" invalid")