feat: patch FIT creator as Edge 1030 Plus

This commit is contained in:
Bastian Wagner
2026-08-15 10:35:36 +02:00
parent a47ad77071
commit 5944ef364f
3 changed files with 340 additions and 1 deletions

View File

@@ -1,4 +1,5 @@
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
@@ -14,3 +15,30 @@ class LocalDefinition:
endian: str
fields: tuple[FieldDefinition, ...]
developer_field_size: int
GARMIN_MANUFACTURER_ID = 1
@dataclass(frozen=True)
class GarminDevice:
manufacturer_id: int = GARMIN_MANUFACTURER_ID
product_id: int = 3570
product_name: str = "Edge 1030 Plus"
serial_number: int | None = None
@dataclass(frozen=True)
class FitConversionResult:
source_path: Path
output_path: Path
patched_field_count: int
header_crc: int | None
file_crc: int
@dataclass(frozen=True)
class DeviceFieldValue:
global_message_num: int
field_num: int
value: int | str

View File

@@ -2,7 +2,16 @@ import struct
from pathlib import Path
from app.fit.crc import fit_crc
from app.fit.models import FieldDefinition, LocalDefinition
from app.fit.models import (
DeviceFieldValue,
FieldDefinition,
FitConversionResult,
GarminDevice,
LocalDefinition,
)
FILE_ID_MESG_NUM = 0
DEVICE_INFO_MESG_NUM = 23
class FitFormatError(ValueError):
@@ -141,3 +150,155 @@ def is_fit_file(path: Path) -> bool:
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)
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)
values: list[DeviceFieldValue] = []
for definition, field_offsets in _iter_data_fields(data):
for field, offset in field_offsets:
if definition.global_message_num == FILE_ID_MESG_NUM and field.num in {1, 2, 3, 8}:
values.append(
DeviceFieldValue(
definition.global_message_num,
field.num,
_read_field_value(data, offset, field, definition.endian),
)
)
if definition.global_message_num == DEVICE_INFO_MESG_NUM and field.num in {
2,
3,
4,
27,
}:
values.append(
DeviceFieldValue(
definition.global_message_num,
field.num,
_read_field_value(data, offset, field, definition.endian),
)
)
return values
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:
device_index_entry = field_map.get(0)
if device_index_entry is None:
continue
index_field, index_offset = device_index_entry
device_index = _read_field_value(data, index_offset, index_field, definition.endian)
if device_index != 0:
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 _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")
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
if field.size == 1:
replacement = struct.pack("B", value)
elif field.size == 2:
replacement = struct.pack(f"{endian}H", value)
elif field.size == 4:
replacement = struct.pack(f"{endian}I", value)
else:
return False
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("<H", data, 12, header_crc)
return header_crc
def _rewrite_file_crc(data: bytearray) -> int:
file_crc = fit_crc(data[:-2])
struct.pack_into("<H", data, len(data) - 2, file_crc)
return file_crc