Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ shipping products.

## Scope

Implements Draft v0.1 **§4 native FIT fields**, **§5.1 core metrics** (IDs 0–3,
6–9, and 19, and **StrokeRate 93**), and Session **RecordingStrategy** (ID 10), under
application UUID `89e86158-6d47-5c98-9d46-7d29437f27b9`.
Implements Draft v0.1 **§4 native FIT fields**, **§5.1 core metrics** (IDs 0–9,
19, and **StrokeRate 93**), and Session **RecordingStrategy** (ID 10), under
application UUID `89e86158-6d47-5c98-9d46-7d29437f27b9`. Producers write
protocol version 1 in `DeveloperDataId.application_version`. A file that omits
it is still read.

§5.1 still requires native `cadence` when rate is known; this library writes
and reads both `cadence` and ID 93.
Expand Down
8 changes: 7 additions & 1 deletion python/src/rowing_data/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""Draft v0.1 Python implementation of the Rowing Data Standard."""

from .codec import CodecError, decode, encode
from .constants import APPLICATION_ID, APPLICATION_UUID, STANDARD_VERSION
from .constants import (
APPLICATION_ID,
APPLICATION_UUID,
PROTOCOL_VERSION,
STANDARD_VERSION,
)
from .fields import (
DEVELOPER_FIELDS,
NATIVE_FIELDS,
Expand All @@ -23,6 +28,7 @@
"Issue",
"Lap",
"NATIVE_FIELDS",
"PROTOCOL_VERSION",
"Record",
"RowingSession",
"STANDARD_VERSION",
Expand Down
5 changes: 5 additions & 0 deletions python/src/rowing_data/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
# Draft version this package implements. Not a ratified standard.
STANDARD_VERSION = "0.1"

# Encoding generation written in DeveloperDataId.application_version.
# Separate from STANDARD_VERSION. Increment only when an existing field changes
# meaning. Absent on files written before this field was defined.
PROTOCOL_VERSION = 1

APPLICATION_UUID: UUID = uuid5(NAMESPACE_DNS, "rowingdata")
APPLICATION_ID: bytes = APPLICATION_UUID.bytes

Expand Down
30 changes: 29 additions & 1 deletion python/src/rowing_data/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from fitparse import FitFile

from .codec import decode
from .constants import APPLICATION_ID, RecordingStrategy
from .constants import APPLICATION_ID, PROTOCOL_VERSION, RecordingStrategy
from .fields import FieldDef, field_by_id, field_by_name
from .model import ATTR_BY_FIELD_ID, Lap, Record, RowingSession
from .validation import Issue
Expand Down Expand Up @@ -39,6 +39,7 @@ def read_fit(path: str | Path) -> RowingSession:
messages = list(fit.messages)
our_indexes = _developer_indexes_for_app(messages)
our_fields, issues = _our_developer_fields(messages, our_indexes)
issues.extend(_protocol_version_issues(messages))

strategy = RecordingStrategy.UNKNOWN
start_time: datetime | None = None
Expand Down Expand Up @@ -97,6 +98,33 @@ def _developer_indexes_for_app(messages: list) -> set[int]:
return indexes


def _protocol_version_issues(messages: list) -> list[Issue]:
"""Warn when the file's protocol version is newer than this sample.

A missing application_version is a file from before the field existed.
Those files are read. They are not rejected and they are not warned.
"""
issues: list[Issue] = []
for message in messages:
if message.name != "developer_data_id":
continue
if _as_bytes(message.get_value("application_id")) != APPLICATION_ID:
continue
version = message.get_value("application_version")
if version is None:
continue
if int(version) > PROTOCOL_VERSION:
issues.append(
Issue(
"warning",
"protocol_version",
f"application_version {int(version)} is newer than "
f"{PROTOCOL_VERSION}; reading the fields this sample knows",
)
)
return issues


def _our_developer_fields(
messages: list, our_indexes: set[int]
) -> tuple[dict[str, FieldDef], list[Issue]]:
Expand Down
3 changes: 2 additions & 1 deletion python/src/rowing_data/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
)

from .codec import encode
from .constants import APPLICATION_ID, CYCLE_LENGTH16_SCALE
from .constants import APPLICATION_ID, CYCLE_LENGTH16_SCALE, PROTOCOL_VERSION
from .fields import BaseType, FieldDef, field_by_id, field_by_name
from .model import (
RECORD_DEVELOPER_ATTRS,
Expand Down Expand Up @@ -192,6 +192,7 @@ def _add_developer_definitions(
) -> None:
dev_id = DeveloperDataIdMessage()
dev_id.application_id = APPLICATION_ID
dev_id.application_version = PROTOCOL_VERSION
dev_id.developer_data_index = _DEV_INDEX
builder.add(dev_id)
builder.add(_field_description(field_by_id(10)))
Expand Down
62 changes: 62 additions & 0 deletions python/tests/test_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,68 @@ def _ts_ms() -> int:
return int(datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC).timestamp() * 1000)


def test_missing_application_version_is_read(tmp_path: Path) -> None:
path = tmp_path / "no_version.fit"
builder = FitFileBuilder(auto_define=True, min_string_size=64)

file_id = FileIdMessage()
file_id.type = FileType.ACTIVITY
file_id.manufacturer = Manufacturer.DEVELOPMENT
file_id.time_created = _ts_ms()
builder.add(file_id)

dev_id = DeveloperDataIdMessage()
dev_id.application_id = APPLICATION_ID
dev_id.developer_data_index = 0
builder.add(dev_id)

session = SessionMessage()
session.start_time = _ts_ms()
session.timestamp = _ts_ms()
builder.add(session)

rec = RecordMessage()
rec.timestamp = _ts_ms()
rec.distance = 12.5
builder.add(rec)
builder.build().to_file(str(path))

loaded = read_fit(path)
assert loaded.records[0].distance_m == 12.5
assert all(issue.code != "protocol_version" for issue in loaded.read_issues)


def test_newer_application_version_still_reads(tmp_path: Path) -> None:
path = tmp_path / "newer.fit"
builder = FitFileBuilder(auto_define=True, min_string_size=64)

file_id = FileIdMessage()
file_id.type = FileType.ACTIVITY
file_id.manufacturer = Manufacturer.DEVELOPMENT
file_id.time_created = _ts_ms()
builder.add(file_id)

dev_id = DeveloperDataIdMessage()
dev_id.application_id = APPLICATION_ID
dev_id.application_version = 99
dev_id.developer_data_index = 0
builder.add(dev_id)

session = SessionMessage()
session.start_time = _ts_ms()
session.timestamp = _ts_ms()
builder.add(session)

rec = RecordMessage()
rec.timestamp = _ts_ms()
builder.add(rec)
builder.build().to_file(str(path))

loaded = read_fit(path)
assert len(loaded.records) == 1
assert any(issue.code == "protocol_version" for issue in loaded.read_issues)


def test_foreign_developer_drive_length_is_ignored(tmp_path: Path) -> None:
path = tmp_path / "foreign.fit"
other_app = uuid4().bytes
Expand Down
5 changes: 4 additions & 1 deletion python/tests/test_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pytest
from fitparse import FitFile

from rowing_data import APPLICATION_ID, APPLICATION_UUID, write_fit
from rowing_data import APPLICATION_ID, APPLICATION_UUID, PROTOCOL_VERSION, write_fit
from rowing_data.codec import CodecError
from rowing_data.constants import RecordingStrategy
from rowing_data.model import Record, RowingSession
Expand Down Expand Up @@ -65,6 +65,9 @@ def test_writer_application_id_and_recording_strategy(tmp_path: Path) -> None:
fit = FitFile(str(path), check_crc=True)
assert _app_id(fit) == APPLICATION_ID
assert APPLICATION_UUID.bytes == APPLICATION_ID
dev_id = next(fit.get_messages("developer_data_id"))
assert dev_id.get_value("application_version") == PROTOCOL_VERSION
assert PROTOCOL_VERSION == 1

descriptions = {
msg.get_value("field_definition_number"): msg.get_value("field_name")
Expand Down
Loading