From bca00b82dea9f783f2ab748836813db2f1bd4484 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 7 Oct 2026 09:20:37 +0200 Subject: [PATCH] Python: write protocol version 1 on DeveloperDataId. Files that omit application_version still load. A newer version warns and is still read. Co-authored-by: Cursor --- python/README.md | 4 +- python/src/rowing_data/__init__.py | 8 +++- python/src/rowing_data/constants.py | 5 +++ python/src/rowing_data/reader.py | 30 +++++++++++++- python/src/rowing_data/writer.py | 3 +- python/tests/test_reader.py | 62 +++++++++++++++++++++++++++++ python/tests/test_writer.py | 5 ++- 7 files changed, 112 insertions(+), 5 deletions(-) diff --git a/python/README.md b/python/README.md index 2d4bfaa..e4eec7e 100644 --- a/python/README.md +++ b/python/README.md @@ -12,7 +12,9 @@ shipping products. 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`. +application UUID `89e86158-6d47-5c98-9d46-7d29437f27b9`. Producers write +protocol version 1 in `DeveloperDataId.application_version`. A file that omits +it is still read. That is conformance **Levels 1–2** as in §7, plus StrokeRate (93). §7’s Level 2 bullet list omits 93; §5.1 still requires native `cadence` when rate is known, diff --git a/python/src/rowing_data/__init__.py b/python/src/rowing_data/__init__.py index 8bf6e76..c794f8e 100644 --- a/python/src/rowing_data/__init__.py +++ b/python/src/rowing_data/__init__.py @@ -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, @@ -23,6 +28,7 @@ "Issue", "Lap", "NATIVE_FIELDS", + "PROTOCOL_VERSION", "Record", "RowingSession", "STANDARD_VERSION", diff --git a/python/src/rowing_data/constants.py b/python/src/rowing_data/constants.py index c2132d8..9a0db92 100644 --- a/python/src/rowing_data/constants.py +++ b/python/src/rowing_data/constants.py @@ -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 diff --git a/python/src/rowing_data/reader.py b/python/src/rowing_data/reader.py index bec34d1..1732735 100644 --- a/python/src/rowing_data/reader.py +++ b/python/src/rowing_data/reader.py @@ -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 @@ -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 @@ -91,6 +92,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]]: diff --git a/python/src/rowing_data/writer.py b/python/src/rowing_data/writer.py index 3ab34dc..0c1accf 100644 --- a/python/src/rowing_data/writer.py +++ b/python/src/rowing_data/writer.py @@ -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, Record, RowingSession from .strokes import native_cadence_parts @@ -169,6 +169,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))) diff --git a/python/tests/test_reader.py b/python/tests/test_reader.py index f795459..8cafa4f 100644 --- a/python/tests/test_reader.py +++ b/python/tests/test_reader.py @@ -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 diff --git a/python/tests/test_writer.py b/python/tests/test_writer.py index 2e3bec0..389f3f0 100644 --- a/python/tests/test_writer.py +++ b/python/tests/test_writer.py @@ -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 @@ -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")