host.match_monitor.ds_log_reader

Locate, copy, and parse FRC Driver Station log files.

DS Log files are stored in C:\Users\Public\Documents\FRC\Log Files\ with names: 2024_01_15_15_23_45 FRC.dslog — 50Hz binary data 2024_01_15_15_23_45 FRC.dsevents — text event log

dsevents format (tab-separated fields per line): \t\t[detail]

dslog binary format (v3, 50Hz): Header (16 bytes): version(4) + start_time_ms(8) + reserved(4) Each record (21 bytes): uint8 packet_loss_pct uint8 trip_time_ms uint16 battery_voltage (big-endian, volts * 256.0) uint8 robot_voltage (volts * 10.2, approx) uint8 status_flags (bit0=estop, bit1=disabled, bit2=auto, bit3=enabled, bit4=robot_code, bit5=ds_link, bit6=fms_link) uint8 can_utilization_pct uint8 signal_quality_pct uint8 robot_cpu_pct uint8 ds_cpu_pct uint8 ram_pct uint16 bandwidth_kbps (big-endian) uint8 pdp_channel_0_current (x 8 ... varies by record type) -- remaining bytes vary --

  1"""
  2Locate, copy, and parse FRC Driver Station log files.
  3
  4DS Log files are stored in C:\\Users\\Public\\Documents\\FRC\\Log Files\\ with names:
  5  2024_01_15_15_23_45 FRC.dslog     — 50Hz binary data
  6  2024_01_15_15_23_45 FRC.dsevents  — text event log
  7
  8dsevents format (tab-separated fields per line):
  9  <seconds_from_log_start>\\t<message>\\t[detail]
 10
 11dslog binary format (v3, 50Hz):
 12  Header (16 bytes): version(4) + start_time_ms(8) + reserved(4)
 13  Each record (21 bytes):
 14    uint8  packet_loss_pct
 15    uint8  trip_time_ms
 16    uint16 battery_voltage  (big-endian, volts * 256.0)
 17    uint8  robot_voltage    (volts * 10.2, approx)
 18    uint8  status_flags     (bit0=estop, bit1=disabled, bit2=auto, bit3=enabled,
 19                             bit4=robot_code, bit5=ds_link, bit6=fms_link)
 20    uint8  can_utilization_pct
 21    uint8  signal_quality_pct
 22    uint8  robot_cpu_pct
 23    uint8  ds_cpu_pct
 24    uint8  ram_pct
 25    uint16 bandwidth_kbps   (big-endian)
 26    uint8  pdp_channel_0_current (x 8 ... varies by record type)
 27    -- remaining bytes vary --
 28"""
 29
 30import logging
 31import re
 32import shutil
 33import struct
 34from dataclasses import dataclass, field
 35from datetime import datetime, timedelta
 36from pathlib import Path
 37from typing import List, Optional
 38
 39logger = logging.getLogger("match_monitor")
 40
 41# Default DS log directory (Windows Driver Station)
 42DS_LOG_DIR = Path(r"C:\Users\Public\Documents\FRC\Log Files")
 43
 44# Filename timestamp pattern: "2024_01_15_15_23_45 FRC"
 45_DS_FNAME_RE = re.compile(
 46    r"(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})\s+FRC\.(dslog|dsevents)$",
 47    re.IGNORECASE,
 48)
 49
 50# How far before/after the match window to look for a DS log file (seconds)
 51_MATCH_WINDOW_SLOP = 300  # 5 minutes
 52
 53
 54@dataclass
 55class DsEvent:
 56    """A single entry from a .dsevents file."""
 57    offset_sec: float     # Seconds from log start
 58    message: str
 59    detail: str = ""
 60
 61
 62@dataclass
 63class DsRecord:
 64    """One 50Hz record from a .dslog file."""
 65    offset_sec: float
 66    packet_loss_pct: int
 67    trip_time_ms: int
 68    battery_voltage: float
 69    signal_quality_pct: int
 70    robot_cpu_pct: int
 71    ds_cpu_pct: int
 72    ram_pct: int
 73    can_utilization_pct: int
 74    bandwidth_kbps: int
 75    enabled: bool
 76    autonomous: bool
 77    fms_link: bool
 78    ds_link: bool
 79
 80
 81@dataclass
 82class DsLogData:
 83    """Parsed content from DS log files for one match."""
 84    log_start_wall: Optional[datetime] = None   # Wall clock of log file start
 85    events: List[DsEvent] = field(default_factory=list)
 86    records: List[DsRecord] = field(default_factory=list)
 87    copied_files: List[str] = field(default_factory=list)
 88
 89    # Derived summary (filled by summarize())
 90    avg_packet_loss_pct: Optional[float] = None
 91    max_trip_time_ms: Optional[int] = None
 92    avg_trip_time_ms: Optional[float] = None
 93    min_battery_v: Optional[float] = None
 94    avg_battery_v: Optional[float] = None
 95    peak_ds_cpu_pct: Optional[int] = None
 96    peak_robot_cpu_pct: Optional[int] = None
 97    peak_ram_pct: Optional[int] = None
 98    peak_bandwidth_kbps: Optional[int] = None
 99
100    def summarize(self) -> None:
101        """Compute derived summary fields from raw records."""
102        if not self.records:
103            return
104        self.avg_packet_loss_pct = round(
105            sum(r.packet_loss_pct for r in self.records) / len(self.records), 1)
106        self.max_trip_time_ms = max(r.trip_time_ms for r in self.records)
107        self.avg_trip_time_ms = round(
108            sum(r.trip_time_ms for r in self.records) / len(self.records), 1)
109        voltages = [r.battery_voltage for r in self.records if r.battery_voltage > 0]
110        if voltages:
111            self.min_battery_v = round(min(voltages), 3)
112            self.avg_battery_v = round(sum(voltages) / len(voltages), 3)
113        self.peak_ds_cpu_pct = max(r.ds_cpu_pct for r in self.records)
114        self.peak_robot_cpu_pct = max(r.robot_cpu_pct for r in self.records)
115        self.peak_ram_pct = max(r.ram_pct for r in self.records)
116        self.peak_bandwidth_kbps = max(r.bandwidth_kbps for r in self.records)
117
118    def to_dict(self) -> dict:
119        d = {
120            'log_start_wall': self.log_start_wall.isoformat() if self.log_start_wall else None,
121            'copied_files': self.copied_files,
122            'event_count': len(self.events),
123            'record_count': len(self.records),
124            'events': [
125                {'offset_sec': e.offset_sec, 'message': e.message, 'detail': e.detail}
126                for e in self.events
127            ],
128        }
129        if self.records:
130            d['summary'] = {
131                'avg_packet_loss_pct': self.avg_packet_loss_pct,
132                'avg_trip_time_ms': self.avg_trip_time_ms,
133                'max_trip_time_ms': self.max_trip_time_ms,
134                'min_battery_v': self.min_battery_v,
135                'avg_battery_v': self.avg_battery_v,
136                'peak_robot_cpu_pct': self.peak_robot_cpu_pct,
137                'peak_ds_cpu_pct': self.peak_ds_cpu_pct,
138                'peak_ram_pct': self.peak_ram_pct,
139                'peak_bandwidth_kbps': self.peak_bandwidth_kbps,
140            }
141        return d
142
143
144def _parse_filename_time(path: Path) -> Optional[datetime]:
145    """Extract datetime from a DS log filename, or None if unrecognized."""
146    m = _DS_FNAME_RE.match(path.name)
147    if not m:
148        return None
149    year, month, day, hour, minute, second = (int(m.group(i)) for i in range(1, 7))
150    try:
151        return datetime(year, month, day, hour, minute, second)
152    except ValueError:
153        return None
154
155
156def find_match_logs(match_time: datetime,
157                    ds_log_dir: Path = DS_LOG_DIR) -> List[Path]:
158    """
159    Find DS log files whose filename timestamp is within MATCH_WINDOW_SLOP
160    seconds of match_time.  Returns a list of .dslog / .dsevents paths.
161    """
162    if not ds_log_dir.is_dir():
163        return []
164
165    window = timedelta(seconds=_MATCH_WINDOW_SLOP)
166    results = []
167    for path in ds_log_dir.iterdir():
168        if path.suffix.lower() not in ('.dslog', '.dsevents'):
169            continue
170        file_time = _parse_filename_time(path)
171        if file_time is None:
172            continue
173        if abs(file_time - match_time) <= window:
174            results.append(path)
175
176    results.sort()
177    return results
178
179
180def copy_ds_logs(paths: List[Path], dest_dir: Path) -> List[str]:
181    """Copy DS log files into dest_dir. Returns list of copied filenames."""
182    copied = []
183    for src in paths:
184        dest = dest_dir / src.name
185        if dest.exists():
186            copied.append(src.name)
187            continue
188        try:
189            shutil.copy2(src, dest)
190            copied.append(src.name)
191            logger.info(f"Copied DS log: {src.name}")
192        except OSError:
193            logger.warning(f"Could not copy DS log: {src.name}")
194    return copied
195
196
197def parse_dsevents(path: Path) -> tuple[Optional[datetime], List[DsEvent]]:
198    """
199    Parse a .dsevents file.  Returns (log_start_wall, [DsEvent, ...]).
200
201    The file contains tab-separated lines:
202        <float_seconds>\\t<message>[\\t<detail>]
203    The log start wall time is inferred from the filename.
204    """
205    log_start = _parse_filename_time(path)
206    events = []
207    try:
208        for line in path.read_text(encoding='utf-8', errors='replace').splitlines():
209            line = line.strip()
210            if not line:
211                continue
212            parts = line.split('\t')
213            if len(parts) < 2:
214                continue
215            try:
216                offset = float(parts[0])
217            except ValueError:
218                continue
219            message = parts[1].strip()
220            detail = parts[2].strip() if len(parts) > 2 else ""
221            events.append(DsEvent(offset_sec=round(offset, 3),
222                                  message=message, detail=detail))
223    except OSError:
224        logger.warning(f"Could not read DS events file: {path.name}")
225    return log_start, events
226
227
228# dslog record: packet_loss(B) trip_time(B) battery_v(H) robot_v(B)
229# status(B) can(B) signal(B) robot_cpu(B) ds_cpu(B) ram(B) bandwidth(H)
230# = 13 bytes; remaining bytes in record vary (PDP dumps etc.)
231_RECORD_FMT = struct.Struct('>BBHBBBBBBBH')       # 12 bytes
232_RECORD_SIZE = 21  # total record size including PDP/trailing bytes
233
234
235def parse_dslog(path: Path) -> tuple[Optional[datetime], List[DsRecord]]:
236    """
237    Parse a .dslog binary file.  Returns (log_start_wall, [DsRecord, ...]).
238
239    Note: The binary format is community-documented and may vary slightly
240    between DS versions.  Fields beyond the core 12 bytes are skipped.
241    """
242    log_start = _parse_filename_time(path)
243    records = []
244    try:
245        data = path.read_bytes()
246    except OSError:
247        logger.warning(f"Could not read DS log file: {path.name}")
248        return log_start, records
249
250    if len(data) < 16:
251        return log_start, records
252
253    # Version check
254    version = struct.unpack_from('>I', data, 0)[0]
255    if version != 3:
256        logger.warning(f"Unsupported dslog version {version} in {path.name}")
257        return log_start, records
258
259    offset = 16  # skip header
260    record_index = 0
261    while offset + _RECORD_FMT.size <= len(data):
262        try:
263            (packet_loss, trip_time, batt_raw, robot_v_raw,
264             status, can_pct, signal_pct, robot_cpu, ds_cpu,
265             ram_pct, bw_kbps) = _RECORD_FMT.unpack_from(data, offset)
266
267            battery_v = batt_raw / 256.0
268            enabled = bool(status & 0x08)
269            autonomous = bool(status & 0x04)
270            ds_link = bool(status & 0x20)
271            fms_link = bool(status & 0x40)
272
273            records.append(DsRecord(
274                offset_sec=round(record_index / 50.0, 3),
275                packet_loss_pct=min(packet_loss, 100),
276                trip_time_ms=trip_time,
277                battery_voltage=round(battery_v, 3),
278                signal_quality_pct=signal_pct,
279                robot_cpu_pct=robot_cpu,
280                ds_cpu_pct=ds_cpu,
281                ram_pct=ram_pct,
282                can_utilization_pct=can_pct,
283                bandwidth_kbps=bw_kbps,
284                enabled=enabled,
285                autonomous=autonomous,
286                fms_link=fms_link,
287                ds_link=ds_link,
288            ))
289        except struct.error:
290            break
291
292        offset += _RECORD_SIZE
293        record_index += 1
294
295    logger.info(f"Parsed {len(records)} records from {path.name}")
296    return log_start, records
297
298
299def collect_for_match(match_time: datetime, dest_dir: Path,
300                      ds_log_dir: Path = DS_LOG_DIR) -> DsLogData:
301    """
302    Find DS logs matching match_time, copy them to dest_dir, parse them,
303    and return a populated DsLogData.
304    """
305    result = DsLogData()
306
307    paths = find_match_logs(match_time, ds_log_dir)
308    if not paths:
309        logger.info("No matching DS log files found")
310        return result
311
312    result.copied_files = copy_ds_logs(paths, dest_dir)
313
314    for path in paths:
315        suffix = path.suffix.lower()
316        local = dest_dir / path.name   # work from the copy
317
318        if suffix == '.dsevents':
319            wall_time, events = parse_dsevents(local)
320            if wall_time and result.log_start_wall is None:
321                result.log_start_wall = wall_time
322            result.events.extend(events)
323
324        elif suffix == '.dslog':
325            wall_time, records = parse_dslog(local)
326            if wall_time and result.log_start_wall is None:
327                result.log_start_wall = wall_time
328            result.records.extend(records)
329
330    result.summarize()
331    return result
logger = <Logger match_monitor (DEBUG)>
DS_LOG_DIR = PosixPath('C:\\Users\\Public\\Documents\\FRC\\Log Files')
@dataclass
class DsEvent:
55@dataclass
56class DsEvent:
57    """A single entry from a .dsevents file."""
58    offset_sec: float     # Seconds from log start
59    message: str
60    detail: str = ""

A single entry from a .dsevents file.

DsEvent(offset_sec: float, message: str, detail: str = '')
offset_sec: float
message: str
detail: str = ''
@dataclass
class DsRecord:
63@dataclass
64class DsRecord:
65    """One 50Hz record from a .dslog file."""
66    offset_sec: float
67    packet_loss_pct: int
68    trip_time_ms: int
69    battery_voltage: float
70    signal_quality_pct: int
71    robot_cpu_pct: int
72    ds_cpu_pct: int
73    ram_pct: int
74    can_utilization_pct: int
75    bandwidth_kbps: int
76    enabled: bool
77    autonomous: bool
78    fms_link: bool
79    ds_link: bool

One 50Hz record from a .dslog file.

DsRecord( offset_sec: float, packet_loss_pct: int, trip_time_ms: int, battery_voltage: float, signal_quality_pct: int, robot_cpu_pct: int, ds_cpu_pct: int, ram_pct: int, can_utilization_pct: int, bandwidth_kbps: int, enabled: bool, autonomous: bool, fms_link: bool, ds_link: bool)
offset_sec: float
packet_loss_pct: int
trip_time_ms: int
battery_voltage: float
signal_quality_pct: int
robot_cpu_pct: int
ds_cpu_pct: int
ram_pct: int
can_utilization_pct: int
bandwidth_kbps: int
enabled: bool
autonomous: bool
@dataclass
class DsLogData:
 82@dataclass
 83class DsLogData:
 84    """Parsed content from DS log files for one match."""
 85    log_start_wall: Optional[datetime] = None   # Wall clock of log file start
 86    events: List[DsEvent] = field(default_factory=list)
 87    records: List[DsRecord] = field(default_factory=list)
 88    copied_files: List[str] = field(default_factory=list)
 89
 90    # Derived summary (filled by summarize())
 91    avg_packet_loss_pct: Optional[float] = None
 92    max_trip_time_ms: Optional[int] = None
 93    avg_trip_time_ms: Optional[float] = None
 94    min_battery_v: Optional[float] = None
 95    avg_battery_v: Optional[float] = None
 96    peak_ds_cpu_pct: Optional[int] = None
 97    peak_robot_cpu_pct: Optional[int] = None
 98    peak_ram_pct: Optional[int] = None
 99    peak_bandwidth_kbps: Optional[int] = None
100
101    def summarize(self) -> None:
102        """Compute derived summary fields from raw records."""
103        if not self.records:
104            return
105        self.avg_packet_loss_pct = round(
106            sum(r.packet_loss_pct for r in self.records) / len(self.records), 1)
107        self.max_trip_time_ms = max(r.trip_time_ms for r in self.records)
108        self.avg_trip_time_ms = round(
109            sum(r.trip_time_ms for r in self.records) / len(self.records), 1)
110        voltages = [r.battery_voltage for r in self.records if r.battery_voltage > 0]
111        if voltages:
112            self.min_battery_v = round(min(voltages), 3)
113            self.avg_battery_v = round(sum(voltages) / len(voltages), 3)
114        self.peak_ds_cpu_pct = max(r.ds_cpu_pct for r in self.records)
115        self.peak_robot_cpu_pct = max(r.robot_cpu_pct for r in self.records)
116        self.peak_ram_pct = max(r.ram_pct for r in self.records)
117        self.peak_bandwidth_kbps = max(r.bandwidth_kbps for r in self.records)
118
119    def to_dict(self) -> dict:
120        d = {
121            'log_start_wall': self.log_start_wall.isoformat() if self.log_start_wall else None,
122            'copied_files': self.copied_files,
123            'event_count': len(self.events),
124            'record_count': len(self.records),
125            'events': [
126                {'offset_sec': e.offset_sec, 'message': e.message, 'detail': e.detail}
127                for e in self.events
128            ],
129        }
130        if self.records:
131            d['summary'] = {
132                'avg_packet_loss_pct': self.avg_packet_loss_pct,
133                'avg_trip_time_ms': self.avg_trip_time_ms,
134                'max_trip_time_ms': self.max_trip_time_ms,
135                'min_battery_v': self.min_battery_v,
136                'avg_battery_v': self.avg_battery_v,
137                'peak_robot_cpu_pct': self.peak_robot_cpu_pct,
138                'peak_ds_cpu_pct': self.peak_ds_cpu_pct,
139                'peak_ram_pct': self.peak_ram_pct,
140                'peak_bandwidth_kbps': self.peak_bandwidth_kbps,
141            }
142        return d

Parsed content from DS log files for one match.

DsLogData( log_start_wall: datetime.datetime | None = None, events: List[DsEvent] = <factory>, records: List[DsRecord] = <factory>, copied_files: List[str] = <factory>, avg_packet_loss_pct: float | None = None, max_trip_time_ms: int | None = None, avg_trip_time_ms: float | None = None, min_battery_v: float | None = None, avg_battery_v: float | None = None, peak_ds_cpu_pct: int | None = None, peak_robot_cpu_pct: int | None = None, peak_ram_pct: int | None = None, peak_bandwidth_kbps: int | None = None)
log_start_wall: datetime.datetime | None = None
events: List[DsEvent]
records: List[DsRecord]
copied_files: List[str]
avg_packet_loss_pct: float | None = None
max_trip_time_ms: int | None = None
avg_trip_time_ms: float | None = None
min_battery_v: float | None = None
avg_battery_v: float | None = None
peak_ds_cpu_pct: int | None = None
peak_robot_cpu_pct: int | None = None
peak_ram_pct: int | None = None
peak_bandwidth_kbps: int | None = None
def summarize(self) -> None:
101    def summarize(self) -> None:
102        """Compute derived summary fields from raw records."""
103        if not self.records:
104            return
105        self.avg_packet_loss_pct = round(
106            sum(r.packet_loss_pct for r in self.records) / len(self.records), 1)
107        self.max_trip_time_ms = max(r.trip_time_ms for r in self.records)
108        self.avg_trip_time_ms = round(
109            sum(r.trip_time_ms for r in self.records) / len(self.records), 1)
110        voltages = [r.battery_voltage for r in self.records if r.battery_voltage > 0]
111        if voltages:
112            self.min_battery_v = round(min(voltages), 3)
113            self.avg_battery_v = round(sum(voltages) / len(voltages), 3)
114        self.peak_ds_cpu_pct = max(r.ds_cpu_pct for r in self.records)
115        self.peak_robot_cpu_pct = max(r.robot_cpu_pct for r in self.records)
116        self.peak_ram_pct = max(r.ram_pct for r in self.records)
117        self.peak_bandwidth_kbps = max(r.bandwidth_kbps for r in self.records)

Compute derived summary fields from raw records.

def to_dict(self) -> dict:
119    def to_dict(self) -> dict:
120        d = {
121            'log_start_wall': self.log_start_wall.isoformat() if self.log_start_wall else None,
122            'copied_files': self.copied_files,
123            'event_count': len(self.events),
124            'record_count': len(self.records),
125            'events': [
126                {'offset_sec': e.offset_sec, 'message': e.message, 'detail': e.detail}
127                for e in self.events
128            ],
129        }
130        if self.records:
131            d['summary'] = {
132                'avg_packet_loss_pct': self.avg_packet_loss_pct,
133                'avg_trip_time_ms': self.avg_trip_time_ms,
134                'max_trip_time_ms': self.max_trip_time_ms,
135                'min_battery_v': self.min_battery_v,
136                'avg_battery_v': self.avg_battery_v,
137                'peak_robot_cpu_pct': self.peak_robot_cpu_pct,
138                'peak_ds_cpu_pct': self.peak_ds_cpu_pct,
139                'peak_ram_pct': self.peak_ram_pct,
140                'peak_bandwidth_kbps': self.peak_bandwidth_kbps,
141            }
142        return d
def find_match_logs( match_time: datetime.datetime, ds_log_dir: pathlib.Path = PosixPath('C:\\Users\\Public\\Documents\\FRC\\Log Files')) -> List[pathlib.Path]:
157def find_match_logs(match_time: datetime,
158                    ds_log_dir: Path = DS_LOG_DIR) -> List[Path]:
159    """
160    Find DS log files whose filename timestamp is within MATCH_WINDOW_SLOP
161    seconds of match_time.  Returns a list of .dslog / .dsevents paths.
162    """
163    if not ds_log_dir.is_dir():
164        return []
165
166    window = timedelta(seconds=_MATCH_WINDOW_SLOP)
167    results = []
168    for path in ds_log_dir.iterdir():
169        if path.suffix.lower() not in ('.dslog', '.dsevents'):
170            continue
171        file_time = _parse_filename_time(path)
172        if file_time is None:
173            continue
174        if abs(file_time - match_time) <= window:
175            results.append(path)
176
177    results.sort()
178    return results

Find DS log files whose filename timestamp is within MATCH_WINDOW_SLOP seconds of match_time. Returns a list of .dslog / .dsevents paths.

def copy_ds_logs(paths: List[pathlib.Path], dest_dir: pathlib.Path) -> List[str]:
181def copy_ds_logs(paths: List[Path], dest_dir: Path) -> List[str]:
182    """Copy DS log files into dest_dir. Returns list of copied filenames."""
183    copied = []
184    for src in paths:
185        dest = dest_dir / src.name
186        if dest.exists():
187            copied.append(src.name)
188            continue
189        try:
190            shutil.copy2(src, dest)
191            copied.append(src.name)
192            logger.info(f"Copied DS log: {src.name}")
193        except OSError:
194            logger.warning(f"Could not copy DS log: {src.name}")
195    return copied

Copy DS log files into dest_dir. Returns list of copied filenames.

def parse_dsevents( path: pathlib.Path) -> tuple[datetime.datetime | None, typing.List[DsEvent]]:
198def parse_dsevents(path: Path) -> tuple[Optional[datetime], List[DsEvent]]:
199    """
200    Parse a .dsevents file.  Returns (log_start_wall, [DsEvent, ...]).
201
202    The file contains tab-separated lines:
203        <float_seconds>\\t<message>[\\t<detail>]
204    The log start wall time is inferred from the filename.
205    """
206    log_start = _parse_filename_time(path)
207    events = []
208    try:
209        for line in path.read_text(encoding='utf-8', errors='replace').splitlines():
210            line = line.strip()
211            if not line:
212                continue
213            parts = line.split('\t')
214            if len(parts) < 2:
215                continue
216            try:
217                offset = float(parts[0])
218            except ValueError:
219                continue
220            message = parts[1].strip()
221            detail = parts[2].strip() if len(parts) > 2 else ""
222            events.append(DsEvent(offset_sec=round(offset, 3),
223                                  message=message, detail=detail))
224    except OSError:
225        logger.warning(f"Could not read DS events file: {path.name}")
226    return log_start, events

Parse a .dsevents file. Returns (log_start_wall, [DsEvent, ...]).

The file contains tab-separated lines: \t[\t] The log start wall time is inferred from the filename.

def parse_dslog( path: pathlib.Path) -> tuple[datetime.datetime | None, typing.List[DsRecord]]:
236def parse_dslog(path: Path) -> tuple[Optional[datetime], List[DsRecord]]:
237    """
238    Parse a .dslog binary file.  Returns (log_start_wall, [DsRecord, ...]).
239
240    Note: The binary format is community-documented and may vary slightly
241    between DS versions.  Fields beyond the core 12 bytes are skipped.
242    """
243    log_start = _parse_filename_time(path)
244    records = []
245    try:
246        data = path.read_bytes()
247    except OSError:
248        logger.warning(f"Could not read DS log file: {path.name}")
249        return log_start, records
250
251    if len(data) < 16:
252        return log_start, records
253
254    # Version check
255    version = struct.unpack_from('>I', data, 0)[0]
256    if version != 3:
257        logger.warning(f"Unsupported dslog version {version} in {path.name}")
258        return log_start, records
259
260    offset = 16  # skip header
261    record_index = 0
262    while offset + _RECORD_FMT.size <= len(data):
263        try:
264            (packet_loss, trip_time, batt_raw, robot_v_raw,
265             status, can_pct, signal_pct, robot_cpu, ds_cpu,
266             ram_pct, bw_kbps) = _RECORD_FMT.unpack_from(data, offset)
267
268            battery_v = batt_raw / 256.0
269            enabled = bool(status & 0x08)
270            autonomous = bool(status & 0x04)
271            ds_link = bool(status & 0x20)
272            fms_link = bool(status & 0x40)
273
274            records.append(DsRecord(
275                offset_sec=round(record_index / 50.0, 3),
276                packet_loss_pct=min(packet_loss, 100),
277                trip_time_ms=trip_time,
278                battery_voltage=round(battery_v, 3),
279                signal_quality_pct=signal_pct,
280                robot_cpu_pct=robot_cpu,
281                ds_cpu_pct=ds_cpu,
282                ram_pct=ram_pct,
283                can_utilization_pct=can_pct,
284                bandwidth_kbps=bw_kbps,
285                enabled=enabled,
286                autonomous=autonomous,
287                fms_link=fms_link,
288                ds_link=ds_link,
289            ))
290        except struct.error:
291            break
292
293        offset += _RECORD_SIZE
294        record_index += 1
295
296    logger.info(f"Parsed {len(records)} records from {path.name}")
297    return log_start, records

Parse a .dslog binary file. Returns (log_start_wall, [DsRecord, ...]).

Note: The binary format is community-documented and may vary slightly between DS versions. Fields beyond the core 12 bytes are skipped.

def collect_for_match( match_time: datetime.datetime, dest_dir: pathlib.Path, ds_log_dir: pathlib.Path = PosixPath('C:\\Users\\Public\\Documents\\FRC\\Log Files')) -> DsLogData:
300def collect_for_match(match_time: datetime, dest_dir: Path,
301                      ds_log_dir: Path = DS_LOG_DIR) -> DsLogData:
302    """
303    Find DS logs matching match_time, copy them to dest_dir, parse them,
304    and return a populated DsLogData.
305    """
306    result = DsLogData()
307
308    paths = find_match_logs(match_time, ds_log_dir)
309    if not paths:
310        logger.info("No matching DS log files found")
311        return result
312
313    result.copied_files = copy_ds_logs(paths, dest_dir)
314
315    for path in paths:
316        suffix = path.suffix.lower()
317        local = dest_dir / path.name   # work from the copy
318
319        if suffix == '.dsevents':
320            wall_time, events = parse_dsevents(local)
321            if wall_time and result.log_start_wall is None:
322                result.log_start_wall = wall_time
323            result.events.extend(events)
324
325        elif suffix == '.dslog':
326            wall_time, records = parse_dslog(local)
327            if wall_time and result.log_start_wall is None:
328                result.log_start_wall = wall_time
329            result.records.extend(records)
330
331    result.summarize()
332    return result

Find DS logs matching match_time, copy them to dest_dir, parse them, and return a populated DsLogData.