reference

This commit is contained in:
Bastian Wagner
2026-08-15 09:04:38 +02:00
parent f6da346e18
commit a2387341bd
3 changed files with 556 additions and 0 deletions

39
reference/fit_crc.py Normal file
View File

@@ -0,0 +1,39 @@
from __future__ import annotations
CRC_TABLE = (
0x0000,
0xCC01,
0xD801,
0x1400,
0xF001,
0x3C00,
0x2800,
0xE401,
0xA001,
0x6C00,
0x7800,
0xB401,
0x5000,
0x9C01,
0x8801,
0x4400,
)
def update_crc(crc: int, byte: int) -> int:
tmp = CRC_TABLE[crc & 0xF]
crc = (crc >> 4) & 0x0FFF
crc = crc ^ tmp ^ CRC_TABLE[byte & 0xF]
tmp = CRC_TABLE[crc & 0xF]
crc = (crc >> 4) & 0x0FFF
crc = crc ^ tmp ^ CRC_TABLE[(byte >> 4) & 0xF]
return crc & 0xFFFF
def fit_crc(data: bytes | bytearray | memoryview) -> int:
crc = 0
for byte in data:
crc = update_crc(crc, byte)
return crc

381
reference/fit_device.py Normal file
View File

@@ -0,0 +1,381 @@
from __future__ import annotations
import logging
import struct
from dataclasses import dataclass
from pathlib import Path
from .fit_crc import fit_crc
logger = logging.getLogger(__name__)
FILE_ID_MESG_NUM = 0
DEVICE_INFO_MESG_NUM = 23
GARMIN_MANUFACTURER_ID = 1
@dataclass(frozen=True)
class GarminDevice:
manufacturer_id: int = GARMIN_MANUFACTURER_ID
product_id: int = 3578
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 FieldDefinition:
num: int
size: int
base_type: int
@dataclass(frozen=True)
class LocalDefinition:
global_message_num: int
endian: str
fields: tuple[FieldDefinition, ...]
record_size: int
developer_field_size: int
@dataclass(frozen=True)
class DeviceFieldValue:
global_message_num: int
field_num: int
value: int | str
class FitFormatError(ValueError):
"""Raised when a file is not a valid enough FIT file for metadata patching."""
def convert_fit_device(
source_path: Path, output_path: Path, device: GarminDevice | None = None
) -> FitConversionResult:
device = device or GarminDevice()
data = bytearray(source_path.read_bytes())
_validate_fit_container(data)
patched_count = _patch_device_metadata(data, device)
header_crc = _rewrite_header_crc(data)
file_crc = _rewrite_file_crc(data)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(data)
logger.info(
"Converted FIT metadata for %s -> %s; patched_fields=%d",
source_path,
output_path,
patched_count,
)
return FitConversionResult(
source_path=source_path,
output_path=output_path,
patched_field_count=patched_count,
header_crc=header_crc,
file_crc=file_crc,
)
def is_fit_file(path: Path) -> bool:
try:
data = path.read_bytes()
_validate_fit_container(data)
except (OSError, FitFormatError):
return False
return True
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 _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("<I", data, 4)[0]
expected_size = header_size + data_size + 2
if len(data) != expected_size:
raise FitFormatError(
f"FIT size mismatch: header says {expected_size} bytes, file has {len(data)}"
)
if header_size == 14:
expected_header_crc = struct.unpack_from("<H", data, 12)[0]
actual_header_crc = fit_crc(data[:12])
if expected_header_crc != actual_header_crc:
raise FitFormatError("FIT header CRC check failed")
expected_file_crc = struct.unpack_from("<H", data, len(data) - 2)[0]
actual_file_crc = fit_crc(data[:-2])
if expected_file_crc != actual_file_crc:
raise FitFormatError("FIT file CRC check failed")
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):
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 _iter_data_fields(
data: bytearray,
) -> list[tuple[LocalDefinition, list[tuple[FieldDefinition, int]]]]:
header_size = data[0]
data_size = struct.unpack_from("<I", data, 4)[0]
offset = header_size
end_offset = header_size + data_size
definitions: dict[int, LocalDefinition] = {}
data_records: list[tuple[LocalDefinition, list[tuple[FieldDefinition, int]]]] = []
while offset < end_offset:
record_header = data[offset]
offset += 1
if record_header & 0x80:
local_message_type = (record_header >> 5) & 0x03
definition = definitions.get(local_message_type)
if definition is None:
raise FitFormatError(
f"Compressed timestamp record used unknown local definition {local_message_type}"
)
field_offsets, offset = _collect_field_offsets(definition, offset)
data_records.append((definition, field_offsets))
continue
local_message_type = 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, local_message_type, has_developer_fields
)
definitions[local_message_type] = definition
continue
definition = definitions.get(local_message_type)
if definition is None:
raise FitFormatError(
f"Data record used unknown local definition {local_message_type}"
)
field_offsets, offset = _collect_field_offsets(definition, offset)
data_records.append((definition, field_offsets))
if offset != end_offset:
raise FitFormatError("FIT parser did not end on data boundary")
return data_records
def _read_definition(
data: bytearray,
offset: int,
local_message_type: int,
has_developer_fields: bool,
) -> tuple[LocalDefinition, int]:
del local_message_type
if offset + 5 > len(data):
raise FitFormatError("Truncated FIT definition message")
offset += 1
architecture = data[offset]
offset += 1
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] = []
record_size = 0
for _ in range(field_count):
if offset + 3 > len(data):
raise FitFormatError("Truncated FIT field definition")
field = FieldDefinition(
num=data[offset],
size=data[offset + 1],
base_type=data[offset + 2],
)
fields.append(field)
record_size += field.size
offset += 3
developer_field_size = 0
if has_developer_fields:
if offset >= len(data):
raise FitFormatError("Truncated FIT developer field count")
developer_field_count = data[offset]
offset += 1
for _ in range(developer_field_count):
if offset + 3 > len(data):
raise FitFormatError("Truncated FIT developer fields")
developer_field_size += data[offset + 1]
offset += 3
record_size += developer_field_size
return (
LocalDefinition(
global_message_num=global_message_num,
endian=endian,
fields=tuple(fields),
record_size=record_size,
developer_field_size=developer_field_size,
),
offset,
)
def _collect_field_offsets(
definition: LocalDefinition, offset: int
) -> tuple[list[tuple[FieldDefinition, int]], int]:
field_offsets: list[tuple[FieldDefinition, int]] = []
current_offset = offset
for field in definition.fields:
field_offsets.append((field, current_offset))
current_offset += field.size
current_offset += definition.developer_field_size
return field_offsets, current_offset
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

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Protocol
from .config import Settings
logger = logging.getLogger(__name__)
class GarminClientProtocol(Protocol):
def login(self, tokenstore: str | None = None) -> Any:
...
def upload_activity(self, activity_path: str) -> Any:
...
@dataclass(frozen=True)
class UploadResult:
status: str
duplicate: bool
garmin_activity_id: str | None
raw_response: Any
class GarminUploadBlocked(RuntimeError):
"""Raised when Garmin login needs user action such as MFA."""
class GarminUploader:
def __init__(
self,
settings: Settings,
client_factory: Callable[..., GarminClientProtocol] | None = None,
) -> None:
self.settings = settings
self._client_factory = client_factory
self._client: GarminClientProtocol | None = None
def upload(self, fit_path: Path) -> UploadResult:
client = self._ensure_client()
try:
response = client.upload_activity(str(fit_path))
except Exception as exc:
if _looks_duplicate_error(exc):
logger.info("Garmin already has activity for %s", fit_path)
return UploadResult(
status="duplicate",
duplicate=True,
garmin_activity_id=None,
raw_response=str(exc),
)
raise
return UploadResult(
status="uploaded",
duplicate=False,
garmin_activity_id=_extract_activity_id(response),
raw_response=response,
)
def _ensure_client(self) -> GarminClientProtocol:
if self._client is not None:
return self._client
factory = self._client_factory or _default_garmin_factory
client = factory(
self.settings.garmin_email,
self.settings.garmin_password,
prompt_mfa=self._prompt_mfa,
)
try:
client.login(str(self.settings.garmin_tokenstore))
except RuntimeError:
raise
except Exception as exc:
if "mfa" in str(exc).lower():
raise GarminUploadBlocked(
"Garmin MFA is required. Set GARMIN_MFA_CODE for one run."
) from exc
raise
self._client = client
return client
def _prompt_mfa(self) -> str:
if self.settings.garmin_mfa_code:
return self.settings.garmin_mfa_code
raise GarminUploadBlocked(
"Garmin requested MFA but GARMIN_MFA_CODE is not set."
)
def _default_garmin_factory(*args: Any, **kwargs: Any) -> GarminClientProtocol:
from garminconnect import Garmin
return Garmin(*args, **kwargs)
def _looks_duplicate_error(exc: Exception) -> bool:
text = str(exc).lower()
return any(token in text for token in ("duplicate", "already exists", "409"))
def _extract_activity_id(response: Any) -> str | None:
if not isinstance(response, dict):
return None
candidates = [
response.get("activityId"),
response.get("activity_id"),
response.get("id"),
]
detailed_import = response.get("detailedImportResult")
if isinstance(detailed_import, dict):
candidates.extend(
[
detailed_import.get("uploadId"),
detailed_import.get("activityId"),
]
)
for key in ("successes", "success", "importedActivities"):
items = response.get(key)
if isinstance(items, list) and items:
first = items[0]
if isinstance(first, dict):
candidates.extend([first.get("activityId"), first.get("id")])
for candidate in candidates:
if candidate is not None:
return str(candidate)
return None