import struct from pathlib import Path from app.fit.crc import fit_crc from app.fit.models import ( DeviceFieldValue, FieldDefinition, FitConversionResult, GarminDevice, LocalDefinition, ) FILE_ID_MESG_NUM = 0 DEVICE_INFO_MESG_NUM = 23 # Device-identity fields read back / patched per message type. FILE_ID_DEVICE_FIELDS = frozenset({1, 2, 3, 8}) DEVICE_INFO_DEVICE_FIELDS = frozenset({2, 3, 4, 27}) # Maximum unsigned value per declared FIT field size, used to reject values that # cannot be represented in the field the source file actually declares. _MAX_UNSIGNED_BY_SIZE = {1: 0xFF, 2: 0xFFFF, 4: 0xFFFFFFFF} class FitFormatError(ValueError): pass def _validate_fit_container(data: bytearray) -> None: if len(data) < 14: raise FitFormatError("FIT file is too small") header_size = data[0] if header_size not in {12, 14}: raise FitFormatError(f"Unsupported FIT header size: {header_size}") if len(data) < header_size + 2: raise FitFormatError("FIT file is shorter than its header") if bytes(data[8:12]) != b".FIT": raise FitFormatError("Missing .FIT signature") data_size = struct.unpack_from(" tuple[LocalDefinition, int]: if offset + 5 > end_offset: raise FitFormatError("Truncated FIT definition message") offset += 1 # reserved byte architecture = data[offset] offset += 1 if architecture not in {0, 1}: raise FitFormatError(f"Unsupported FIT architecture: {architecture}") endian = ">" if architecture == 1 else "<" global_message_num = struct.unpack_from(f"{endian}H", data, offset)[0] offset += 2 field_count = data[offset] offset += 1 fields: list[FieldDefinition] = [] for _ in range(field_count): if offset + 3 > end_offset: raise FitFormatError("Truncated FIT field definition") field_num = data[offset] field_size = data[offset + 1] # A zero-size field is illegal FIT. Rejecting it here (rather than # special-casing it downstream) closes a hole where a zero-size # device_info field 0 would read back as device_index == 0 via # int.from_bytes(b"", ...) and make a sensor record look like the creator. if field_size == 0: raise FitFormatError(f"FIT field {field_num} declares an invalid size of 0") fields.append(FieldDefinition(field_num, field_size, data[offset + 2])) offset += 3 developer_field_size = 0 if has_developer_fields: if offset >= end_offset: raise FitFormatError("Truncated FIT developer field count") developer_count = data[offset] offset += 1 for _ in range(developer_count): if offset + 3 > end_offset: raise FitFormatError("Truncated FIT developer field definition") developer_field_size += data[offset + 1] offset += 3 return LocalDefinition(global_message_num, endian, tuple(fields), developer_field_size), offset def _collect_field_offsets( definition: LocalDefinition, offset: int, end_offset: int, ) -> tuple[list[tuple[FieldDefinition, int]], int]: result: list[tuple[FieldDefinition, int]] = [] current = offset for field in definition.fields: if current + field.size > end_offset: raise FitFormatError("Truncated FIT data record") result.append((field, current)) current += field.size if current + definition.developer_field_size > end_offset: raise FitFormatError("Truncated FIT developer field payload") current += definition.developer_field_size return result, current def _iter_data_fields( data: bytearray, ) -> list[tuple[LocalDefinition, list[tuple[FieldDefinition, int]]]]: """Walk the FIT data section and return every data record with its field offsets. Caller invariant: callers MUST run ``_validate_fit_container(data)`` first. The header-derived ``end_offset`` is trusted as-is and never clamped to ``len(data)``, so an unvalidated buffer whose declared data size exceeds its real length would be parsed out of bounds instead of rejected cleanly. """ header_size = data[0] data_size = struct.unpack_from("> 5) & 0x03 definition = definitions.get(local) if definition is None: raise FitFormatError(f"Compressed timestamp record used unknown local definition {local}") field_offsets, offset = _collect_field_offsets(definition, offset, end_offset) records.append((definition, field_offsets)) continue local = record_header & 0x0F is_definition = bool(record_header & 0x40) has_developer_fields = bool(record_header & 0x20) if is_definition: definition, offset = _read_definition(data, offset, has_developer_fields, end_offset) definitions[local] = definition continue definition = definitions.get(local) if definition is None: raise FitFormatError(f"Data record used unknown local definition {local}") field_offsets, offset = _collect_field_offsets(definition, offset, end_offset) records.append((definition, field_offsets)) if offset != end_offset: raise FitFormatError("FIT parser did not end on data boundary") return records def is_fit_file(path: Path) -> bool: try: data = bytearray(path.read_bytes()) _validate_fit_container(data) _iter_data_fields(data) except (OSError, FitFormatError): return False return True def convert_fit_device( source_path: Path, output_path: Path, device: GarminDevice | None = None ) -> FitConversionResult: resolved = device or GarminDevice() data = bytearray(source_path.read_bytes()) _validate_fit_container(data) patched_count = _patch_device_metadata(data, resolved) header_crc = _rewrite_header_crc(data) file_crc = _rewrite_file_crc(data) _validate_fit_container(data) _verify_patched_metadata(data, resolved) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_bytes(data) return FitConversionResult(source_path, output_path, patched_count, header_crc, file_crc) def read_device_field_values(path: Path) -> list[DeviceFieldValue]: data = bytearray(path.read_bytes()) _validate_fit_container(data) return _read_device_field_values(data) def _read_device_field_values(data: bytearray) -> list[DeviceFieldValue]: values: list[DeviceFieldValue] = [] for definition, field_offsets in _iter_data_fields(data): if definition.global_message_num == FILE_ID_MESG_NUM: interesting_fields = FILE_ID_DEVICE_FIELDS is_creator = True elif definition.global_message_num == DEVICE_INFO_MESG_NUM: interesting_fields = DEVICE_INFO_DEVICE_FIELDS field_map = {field.num: (field, offset) for field, offset in field_offsets} is_creator = _is_creator_device_info(data, definition, field_map) else: continue for field, offset in field_offsets: if field.num not in interesting_fields: continue values.append( DeviceFieldValue( definition.global_message_num, field.num, _read_field_value(data, offset, field, definition.endian), is_creator, ) ) return values def _is_creator_device_info( data: bytearray, definition: LocalDefinition, field_map: dict[int, tuple[FieldDefinition, int]], ) -> bool: """A ``device_info`` record counts as the creator only when it carries an explicit device_index (field 0) equal to 0. Records without field 0 are never treated as the creator, so paired sensors are never rewritten.""" device_index_entry = field_map.get(0) if device_index_entry is None: return False index_field, index_offset = device_index_entry return _read_field_value(data, index_offset, index_field, definition.endian) == 0 def _patch_device_metadata(data: bytearray, device: GarminDevice) -> int: patched_count = 0 eligible_field_count = 0 for definition, field_offsets in _iter_data_fields(data): field_map = {field.num: (field, offset) for field, offset in field_offsets} if definition.global_message_num == DEVICE_INFO_MESG_NUM and not _is_creator_device_info( data, definition, field_map ): continue for field, offset in field_offsets: target_value: int | str | None = None if definition.global_message_num == FILE_ID_MESG_NUM: if field.num == 1: target_value = device.manufacturer_id elif field.num == 2: target_value = device.product_id elif field.num == 3 and device.serial_number is not None: target_value = device.serial_number elif field.num == 8: target_value = device.product_name elif definition.global_message_num == DEVICE_INFO_MESG_NUM: if field.num == 2: target_value = device.manufacturer_id elif field.num == 3 and device.serial_number is not None: target_value = device.serial_number elif field.num == 4: target_value = device.product_id elif field.num == 27: target_value = device.product_name if target_value is not None: eligible_field_count += 1 if _write_field_value(data, offset, field, definition.endian, target_value): patched_count += 1 if eligible_field_count == 0: raise FitFormatError("No writable file_id or device_info device fields found") return patched_count def _expected_target_value( global_message_num: int, field_num: int, device: GarminDevice ) -> int | str | None: """Target value a patched device-identity field must read back as, or None for fields that are not verified (e.g. the optional serial number).""" if global_message_num == FILE_ID_MESG_NUM: return {1: device.manufacturer_id, 2: device.product_id, 8: device.product_name}.get(field_num) if global_message_num == DEVICE_INFO_MESG_NUM: return {2: device.manufacturer_id, 4: device.product_id, 27: device.product_name}.get(field_num) return None def _verify_patched_metadata(data: bytearray, device: GarminDevice) -> None: """Spec 10.4 post-patch step: verify the expected target metadata is readable. Reads the patched buffer back and confirms every device-identity field that was present in the source (file_id 1/2/8 and creator device_info 2/4/27) now holds the target value. A field that could not be written -- e.g. a product_name field too small for the target string -- fails the whole conversion with FitFormatError instead of silently producing a half-rewritten file. """ for value in _read_device_field_values(data): if not value.is_creator: continue expected = _expected_target_value(value.global_message_num, value.field_num, device) if expected is None: continue if value.value != expected: raise FitFormatError( f"Patched FIT metadata verification failed for message " f"{value.global_message_num} field {value.field_num}: " f"expected {expected!r}, read back {value.value!r}" ) def _read_field_value(data: bytearray, offset: int, field: FieldDefinition, endian: str) -> int | str: base_type = field.base_type & 0x1F if base_type in {0x03, 0x04, 0x0B} and field.size >= 2: return struct.unpack_from(f"{endian}H", data, offset)[0] if base_type in {0x05, 0x06, 0x0C} and field.size >= 4: return struct.unpack_from(f"{endian}I", data, offset)[0] if base_type == 0x07: raw = bytes(data[offset : offset + field.size]) if 0 in raw: raw = raw[: raw.index(0)] return raw.decode("utf-8", errors="replace") raw = bytes(data[offset : offset + field.size]) return int.from_bytes(raw, "little" if endian == "<" else "big") def _write_field_value( data: bytearray, offset: int, field: FieldDefinition, endian: str, value: int | str, ) -> bool: if isinstance(value, str): encoded = value.encode("utf-8") if not encoded or field.size == 0 or len(encoded) + 1 > field.size: return False replacement = encoded + b"\x00" + b"\x00" * (field.size - len(encoded) - 1) if bytes(data[offset : offset + field.size]) == replacement: return False data[offset : offset + field.size] = replacement return True max_value = _MAX_UNSIGNED_BY_SIZE.get(field.size) if max_value is None: return False # Range-check before packing: struct.pack would otherwise raise a raw # struct.error, which callers cannot classify alongside FitFormatError. if not isinstance(value, int) or value < 0 or value > max_value: raise FitFormatError( f"Value {value!r} does not fit FIT field {field.num} " f"of declared size {field.size} (allowed range 0-{max_value})" ) if field.size == 1: replacement = struct.pack("B", value) elif field.size == 2: replacement = struct.pack(f"{endian}H", value) else: replacement = struct.pack(f"{endian}I", value) if bytes(data[offset : offset + field.size]) == replacement: return False data[offset : offset + field.size] = replacement return True def _rewrite_header_crc(data: bytearray) -> int | None: header_size = data[0] if header_size != 14: return None header_crc = fit_crc(data[:12]) struct.pack_into(" int: file_crc = fit_crc(data[:-2]) struct.pack_into("