James Kuszmaul | cf32412 | 2023-01-14 14:07:17 -0800 | [diff] [blame] | 1 | #! /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 | |
| 6 | import array |
| 7 | import struct |
James Kuszmaul | b13e13f | 2023-11-22 20:44:04 -0800 | [diff] [blame^] | 8 | import msgpack |
James Kuszmaul | cf32412 | 2023-01-14 14:07:17 -0800 | [diff] [blame] | 9 | from typing import List, SupportsBytes |
| 10 | |
| 11 | __all__ = ["StartRecordData", "MetadataRecordData", "DataLogRecord", "DataLogReader"] |
| 12 | |
| 13 | floatStruct = struct.Struct("<f") |
| 14 | doubleStruct = struct.Struct("<d") |
| 15 | |
| 16 | kControlStart = 0 |
| 17 | kControlFinish = 1 |
| 18 | kControlSetMetadata = 2 |
| 19 | |
| 20 | |
| 21 | class 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 | |
| 38 | class 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 | |
| 52 | class 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 Kuszmaul | b13e13f | 2023-11-22 20:44:04 -0800 | [diff] [blame^] | 132 | def getMsgPack(self): |
| 133 | return msgpack.unpackb(self.data) |
| 134 | |
James Kuszmaul | cf32412 | 2023-01-14 14:07:17 -0800 | [diff] [blame] | 135 | 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 | |
| 180 | class 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 | |
| 219 | class 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 | |
| 262 | if __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 Kuszmaul | b13e13f | 2023-11-22 20:44:04 -0800 | [diff] [blame^] | 333 | elif entry.type == "msgpack": |
| 334 | print(f" '{record.getMsgPack()}'") |
James Kuszmaul | cf32412 | 2023-01-14 14:07:17 -0800 | [diff] [blame] | 335 | 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") |