diff --git a/README.md b/README.md index 2cd8269..9b94f0c 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ package already implements. Profile version in use: `21.205.0` (see | Status | Capabilities | | --- | --- | | **Supported** | Common Activity and Workout read/write via typed profile messages; `FitFileBuilder` encode path; **header CRC** (14-byte headers) and **file-level CRC** on load / stream exhaustion (`check_crc=True` default); developer fields for common declaration patterns; streaming iterators (`FitFile.iter_file` / `iter_stream`); CSV export (`to_csv` / `to_rows`); Course and similar message types when you construct them yourself; **chained multi-segment** FIT decode via `from_bytes` / `from_file` (all segments projected into `records`); **compressed timestamp** reconstruction into field 253; **subfield resolution** (ref-field match → type / scale / offset / units; multi-ref AND; first match wins); **component expansion** for all Profile main-field sources (generated registry) **and** components on the active subfield; nested expansion + accumulator rollover; **unknown field ids** on known messages as `UnknownField` (decoded values + `raw_bytes`); **encode modes** `EncodeMode.PRESERVE` (default; unedited bit-identical + post-edit dirty re-project) and `EncodeMode.CANONICAL` (full re-project, normalized sizes/CRCs; optional `strict=True` precheck) — see [Encode policies](#encode-policies) | -| **Partial** | Unknown global messages via `GenericMessage` (readable; unedited preserve keeps wire bytes; post-edit re-encodes dirty records only); composable validation API (`validate_fit_file` / `FitFile.validate`) with WIRE + PROFILE + **Activity and Workout** FILE_TYPE levels — **PROFILE validation is CORE today** (developer-field subset + **ambiguous subfield** ERROR); opt-in **PRESERVATION** level reports unknown-field `raw_bytes` loss after edits; architecture decision **O1** keeps bundled `Profile.xlsx` as the full metadata source of truth with future DOMAIN/FULL scopes (FULL opt-in, not default strict) — see [`docs/FIT_CONFORMANCE_DESIGN.md`](docs/FIT_CONFORMANCE_DESIGN.md) §3.1; Builder `strict=True` is a thin wrapper over the same checks (WIRE+PROFILE+FILE_TYPE only) | -| **Not supported / incomplete** | Full PROFILE semantics (native field requirements, enums, units beyond subfield scale/units); file-type rules for types other than Activity/Workout (e.g. Course — FILE_TYPE fails closed); intentional `repair()` API (strict path never silent-repairs); bit-identical rewrite of compressed-timestamp dirty records when field 253 is not on the definition (strict raises; non-strict keeps compressed header) | +| **Partial** | Unknown global messages via `GenericMessage` (readable; unedited preserve keeps wire bytes; post-edit re-encodes dirty records only); composable validation API (`validate_fit_file` / `FitFile.validate`) with WIRE + PROFILE + **Activity, Workout, and Course** FILE_TYPE levels — **PROFILE validation is CORE today** (developer-field subset + **ambiguous subfield** ERROR); opt-in **PRESERVATION** level reports unknown-field `raw_bytes` loss after edits; architecture decision **O1** keeps bundled `Profile.xlsx` as the full metadata source of truth with future DOMAIN/FULL scopes (FULL opt-in, not default strict) — see [`docs/FIT_CONFORMANCE_DESIGN.md`](docs/FIT_CONFORMANCE_DESIGN.md) §3.1; Builder `strict=True` is a thin wrapper over the same checks (WIRE+PROFILE+FILE_TYPE only) | +| **Not supported / incomplete** | Full PROFILE semantics (native field requirements, enums, units beyond subfield scale/units); file-type rules for types other than Activity/Workout/Course (FILE_TYPE fails closed for remaining types); intentional `repair()` API (strict path never silent-repairs); bit-identical rewrite of compressed-timestamp dirty records when field 253 is not on the definition (strict raises; non-strict keeps compressed header) | If you need a construct listed as incomplete, prefer an official Garmin SDK or wait for the phased work in the design doc (including the **Remaining gaps** @@ -221,7 +221,7 @@ Validation is a first-class API. Levels match the design doc | --- | --- | --- | | `ConformanceLevel.WIRE` | Local IDs, definition field layout/sizes, data records vs active definition | Implemented | | `ConformanceLevel.PROFILE` | Developer field declarations (`developer_data_id` / `field_description`) and base-type consistency; **ambiguous native subfields** (more than one Profile match) as ERROR | **CORE scope today** (+ ambiguous-subfield ERROR). Roadmap: DOMAIN then FULL rules from Profile.xlsx; FULL is opt-in, not default `strict` — design doc §3.1 (O1). Subfield *resolution* for decode/encode is separate and supported | -| `ConformanceLevel.FILE_TYPE` | `file_id` first/unique + required fields; Activity and Workout required messages and fields | **Activity + Workout**; other `file_id.type` values (e.g. Course) **fail closed** (intentional until more validators exist) | +| `ConformanceLevel.FILE_TYPE` | `file_id` first/unique + required fields; Activity, Workout, and Course required messages and fields | **Activity + Workout + Course**; other `file_id.type` values **fail closed** (intentional until more validators exist) | | `ConformanceLevel.PRESERVATION` | Post-edit rewrite loss (e.g. `UnknownField.raw_bytes` cleared by mutation) | **Opt-in** — not in default levels / Builder `strict=True` | Call validation on any `FitFile` or record list — after decode or before encode: diff --git a/fit_tool/tests/test_validation.py b/fit_tool/tests/test_validation.py index 297dbd0..422cf0c 100644 --- a/fit_tool/tests/test_validation.py +++ b/fit_tool/tests/test_validation.py @@ -1,19 +1,22 @@ +import os import unittest from fit_tool.base_type import BaseType from fit_tool.developer_field import DeveloperField from fit_tool.exceptions import FitValidationError +from fit_tool.fit_file import FitFile from fit_tool.fit_file_builder import FitFileBuilder from fit_tool.profile.messages.activity_message import ActivityMessage +from fit_tool.profile.messages.course_message import CourseMessage from fit_tool.profile.messages.developer_data_id_message import DeveloperDataIdMessage +from fit_tool.profile.messages.event_message import EventMessage from fit_tool.profile.messages.field_description_message import FieldDescriptionMessage from fit_tool.profile.messages.file_id_message import FileIdMessage from fit_tool.profile.messages.lap_message import LapMessage from fit_tool.profile.messages.record_message import RecordMessage from fit_tool.profile.messages.session_message import SessionMessage -from fit_tool.profile.messages.workout_message import WorkoutMessage from fit_tool.profile.messages.workout_step_message import WorkoutStepMessage -from fit_tool.profile.profile_type import FileType, Manufacturer, Sport, WorkoutStepDuration, WorkoutStepTarget +from fit_tool.profile.profile_type import Event, EventType, FileType, Manufacturer, Sport from fit_tool.validation import ( ConformanceLevel, FitFileValidator, @@ -61,27 +64,51 @@ def add_minimal_activity_messages(builder, record_message=None): builder.add(activity) -def add_minimal_workout_messages(builder, step_message=None): +def add_minimal_course_messages(builder, *, include_course=True, include_lap=True, + include_record=True, include_timer_start=True, + include_timer_stop=True, course_name='test course'): + """Build a minimal legal Course file (file_id + course + lap + records + timers).""" file_id = FileIdMessage() - file_id.type = FileType.WORKOUT + file_id.type = FileType.COURSE file_id.manufacturer = Manufacturer.DEVELOPMENT.value file_id.product = 0 file_id.serial_number = 1234 file_id.time_created = 1_700_000_000_000 builder.add(file_id) - workout = WorkoutMessage() - workout.num_valid_steps = 1 - workout.sport = Sport.CYCLING - builder.add(workout) + if include_course: + course = CourseMessage() + course.course_name = course_name + course.sport = Sport.CYCLING + builder.add(course) - step = step_message if step_message is not None else WorkoutStepMessage() - step.message_index = 0 - step.duration_type = WorkoutStepDuration.TIME - step.duration_time = 600.0 - step.target_type = WorkoutStepTarget.OPEN - step.target_value = 0 - builder.add(step) + if include_lap: + lap = LapMessage() + lap.timestamp = 1_700_000_001_000 + lap.start_time = 1_700_000_000_000 + lap.total_elapsed_time = 1.0 + builder.add(lap) + + if include_timer_start: + start = EventMessage() + start.event = Event.TIMER + start.event_type = EventType.START + start.timestamp = 1_700_000_000_000 + builder.add(start) + + if include_record: + record = RecordMessage() + record.timestamp = 1_700_000_000_000 + record.position_lat = 40.0 + record.position_long = -105.0 + builder.add(record) + + if include_timer_stop: + stop = EventMessage() + stop.event = Event.TIMER + stop.event_type = EventType.STOP_ALL + stop.timestamp = 1_700_000_001_000 + builder.add(stop) class TestFitValidation(unittest.TestCase): @@ -122,10 +149,10 @@ def test_strict_activity_accepts_required_message_structure(self): self.assertGreater(len(encoded), 0) def test_strict_validation_fails_closed_for_unsupported_file_type(self): - # Course (and other non-Activity/non-Workout types) still fail closed. builder = FitFileBuilder(strict=True) file_id = FileIdMessage() - file_id.type = FileType.COURSE + # Settings (and other non-Activity/Workout/Course types) still fail closed. + file_id.type = FileType.SETTINGS file_id.manufacturer = Manufacturer.DEVELOPMENT.value file_id.product = 0 file_id.serial_number = 1234 @@ -135,26 +162,200 @@ def test_strict_validation_fails_closed_for_unsupported_file_type(self): with self.assertRaisesRegex(FitValidationError, 'not implemented'): builder.build_bytes() - def test_strict_workout_accepts_required_message_structure(self): - builder = FitFileBuilder(strict=True, auto_define=True, min_string_size=50) - add_minimal_workout_messages(builder) + def test_strict_course_accepts_required_message_structure(self): + builder = FitFileBuilder(strict=True) + add_minimal_course_messages(builder) encoded = builder.build_bytes() self.assertGreater(len(encoded), 0) - def test_strict_workout_requires_workout_and_steps(self): + def test_strict_course_requires_course_message(self): + builder = FitFileBuilder(strict=True) + add_minimal_course_messages(builder, include_course=False) + + with self.assertRaisesRegex(FitValidationError, 'course message'): + builder.build_bytes() + + def test_strict_course_requires_lap_and_record(self): + builder = FitFileBuilder(strict=True) + add_minimal_course_messages(builder, include_lap=False) + + with self.assertRaisesRegex(FitValidationError, 'lap message'): + builder.build_bytes() + + builder = FitFileBuilder(strict=True) + add_minimal_course_messages(builder, include_record=False) + + with self.assertRaisesRegex(FitValidationError, 'record message'): + builder.build_bytes() + + def test_strict_course_requires_timer_events(self): + builder = FitFileBuilder(strict=True) + add_minimal_course_messages(builder, include_timer_start=False) + + with self.assertRaisesRegex(FitValidationError, 'timer start'): + builder.build_bytes() + builder = FitFileBuilder(strict=True) + add_minimal_course_messages(builder, include_timer_stop=False) + + with self.assertRaisesRegex(FitValidationError, 'timer stop'): + builder.build_bytes() + + def test_course_missing_sport_reports_file_type_error(self): + """Codex P1: course.sport is required Course metadata.""" + builder = FitFileBuilder(auto_define=True, min_string_size=20) + add_minimal_course_messages(builder, include_course=False) + course = CourseMessage() + course.course_name = 'no sport' + # sport intentionally unset + builder.add(course) + # Re-add remaining structure without a course was already partial — rebuild fully. + builder = FitFileBuilder(auto_define=True, min_string_size=20) file_id = FileIdMessage() - file_id.type = FileType.WORKOUT + file_id.type = FileType.COURSE file_id.manufacturer = Manufacturer.DEVELOPMENT.value file_id.product = 0 file_id.serial_number = 1234 file_id.time_created = 1_700_000_000_000 builder.add(file_id) + course = CourseMessage() + course.course_name = 'no sport' + builder.add(course) + lap = LapMessage() + lap.timestamp = 1_700_000_001_000 + lap.start_time = 1_700_000_000_000 + lap.total_elapsed_time = 1.0 + builder.add(lap) + start = EventMessage() + start.event = Event.TIMER + start.event_type = EventType.START + start.timestamp = 1_700_000_000_000 + builder.add(start) + record = RecordMessage() + record.timestamp = 1_700_000_000_000 + record.position_lat = 40.0 + record.position_long = -105.0 + builder.add(record) + stop = EventMessage() + stop.event = Event.TIMER + stop.event_type = EventType.STOP_ALL + stop.timestamp = 1_700_000_001_000 + builder.add(stop) + report = validate_fit_file(builder.build(), levels={ConformanceLevel.FILE_TYPE}) + self.assertTrue(report.has_errors) + self.assertTrue(any('sport' in f.message for f in report.errors), [f.message for f in report.errors]) - with self.assertRaisesRegex(FitValidationError, 'workout'): - builder.build_bytes() + def test_course_timer_stop_before_start_errors(self): + """Codex P2: timer start must precede timer stop.""" + builder = FitFileBuilder(auto_define=True, min_string_size=20) + file_id = FileIdMessage() + file_id.type = FileType.COURSE + file_id.manufacturer = Manufacturer.DEVELOPMENT.value + file_id.product = 0 + file_id.serial_number = 1 + file_id.time_created = 1_700_000_000_000 + builder.add(file_id) + course = CourseMessage() + course.course_name = 'order' + course.sport = Sport.CYCLING + builder.add(course) + lap = LapMessage() + lap.timestamp = 1_700_000_001_000 + lap.start_time = 1_700_000_000_000 + lap.total_elapsed_time = 1.0 + builder.add(lap) + # stop before start + stop = EventMessage() + stop.event = Event.TIMER + stop.event_type = EventType.STOP_ALL + stop.timestamp = 1_700_000_001_000 + builder.add(stop) + start = EventMessage() + start.event = Event.TIMER + start.event_type = EventType.START + start.timestamp = 1_700_000_000_000 + builder.add(start) + record = RecordMessage() + record.position_lat = 40.0 + record.position_long = -105.0 + builder.add(record) + report = validate_fit_file(builder.build(), levels={ConformanceLevel.FILE_TYPE}) + self.assertTrue(report.has_errors) + self.assertTrue( + any('precede the timer stop' in f.message for f in report.errors), + [f.message for f in report.errors], + ) + + def test_course_missing_name_reports_file_type_error(self): + builder = FitFileBuilder() + file_id = FileIdMessage() + file_id.type = FileType.COURSE + file_id.manufacturer = Manufacturer.DEVELOPMENT.value + file_id.product = 0 + file_id.serial_number = 1234 + file_id.time_created = 1_700_000_000_000 + builder.add(file_id) + course = CourseMessage() + course.sport = Sport.CYCLING # name left unset + builder.add(course) + lap = LapMessage() + lap.timestamp = 1_700_000_001_000 + lap.start_time = 1_700_000_000_000 + lap.total_elapsed_time = 1.0 + builder.add(lap) + start = EventMessage() + start.event = Event.TIMER + start.event_type = EventType.START + start.timestamp = 1_700_000_000_000 + builder.add(start) + record = RecordMessage() + record.timestamp = 1_700_000_000_000 + builder.add(record) + stop = EventMessage() + stop.event = Event.TIMER + stop.event_type = EventType.STOP_DISABLE_ALL + stop.timestamp = 1_700_000_001_000 + builder.add(stop) + fit_file = builder.build() + + report = validate_fit_file(fit_file, levels={ConformanceLevel.FILE_TYPE}) + + self.assertTrue(report.has_errors) + self.assertTrue(any('course_name' in f.message for f in report.errors)) + + def test_course_fixture_stages_link_passes_file_type(self): + path = os.path.join(os.path.dirname(__file__), 'data', 'stagesLink_28832.fit') + fit_file = FitFile.from_file(path) + + report = validate_fit_file(fit_file, levels={ConformanceLevel.FILE_TYPE}) + + self.assertFalse(report.has_errors, msg=report.findings) + + def test_course_accepts_stop_disable_all_timer_event(self): + """Real Garmin courses often use STOP_DISABLE_ALL (9) instead of STOP_ALL.""" + builder = FitFileBuilder() + add_minimal_course_messages(builder, include_timer_stop=False) + stop = EventMessage() + stop.event = Event.TIMER + stop.event_type = EventType.STOP_DISABLE_ALL + stop.timestamp = 1_700_000_001_000 + builder.add(stop) + fit_file = builder.build() + + report = validate_fit_file(fit_file, levels={ConformanceLevel.FILE_TYPE}) + + self.assertFalse(report.has_errors, msg=report.findings) + + def test_activity_file_type_unchanged_with_course_rules(self): + builder = FitFileBuilder() + add_minimal_activity_messages(builder) + fit_file = builder.build() + + report = validate_fit_file(fit_file, levels={ConformanceLevel.FILE_TYPE}) + + self.assertFalse(report.has_errors) def test_strict_validation_rejects_undeclared_developer_field(self): developer_field = DeveloperField( @@ -264,7 +465,7 @@ def test_validate_fit_file_raise_mode_matches_strict_builder(self): def test_validate_wire_only_skips_file_type_rules(self): builder = FitFileBuilder() file_id = FileIdMessage() - file_id.type = FileType.COURSE + file_id.type = FileType.SETTINGS file_id.manufacturer = Manufacturer.DEVELOPMENT.value file_id.product = 0 file_id.serial_number = 1234 @@ -281,15 +482,6 @@ def test_validate_wire_only_skips_file_type_rules(self): any('not implemented' in finding.message for finding in full_report.errors) ) - def test_validate_workout_file_type_report(self): - builder = FitFileBuilder(auto_define=True, min_string_size=50) - add_minimal_workout_messages(builder) - fit_file = builder.build() - - report = validate_fit_file(fit_file, levels={ConformanceLevel.FILE_TYPE}) - self.assertFalse(report.has_errors) - self.assertEqual(report.findings, []) - def test_fit_file_validator_legacy_facade(self): builder = FitFileBuilder() add_minimal_activity_messages(builder) @@ -717,27 +909,6 @@ def test_file_type_findings_structure_errors(self): self.assertTrue(report5.has_errors) self.assertTrue(any('missing required field' in f.message for f in report5.errors)) - # Workout: missing required step fields - w_builder = FitFileBuilder(auto_define=True, min_string_size=20) - w_file_id = FileIdMessage() - w_file_id.type = FileType.WORKOUT - w_file_id.manufacturer = Manufacturer.DEVELOPMENT.value - w_file_id.product = 0 - w_file_id.serial_number = 1 - w_file_id.time_created = 1_700_000_000_000 - w_builder.add(w_file_id) - w_msg = WorkoutMessage() - # num_valid_steps intentionally unset - w_builder.add(w_msg) - bare_step = WorkoutStepMessage() - bare_step.workout_step_name = 'x' - # message_index / duration_type / target_type unset - w_builder.add(bare_step) - w_fit = w_builder.build() - report6 = validate_fit_file(w_fit, levels={ConformanceLevel.FILE_TYPE}) - self.assertTrue(report6.has_errors) - self.assertTrue(any('missing required field' in f.message for f in report6.errors)) - def test_legacy_validator_helpers(self): builder = FitFileBuilder() add_minimal_activity_messages(builder) diff --git a/fit_tool/validation.py b/fit_tool/validation.py index d854aa9..f203cae 100644 --- a/fit_tool/validation.py +++ b/fit_tool/validation.py @@ -10,14 +10,14 @@ ``field_description``) plus **ambiguous native subfield** matches. This is **not** full Garmin Profile validation (enums, units, required native fields per message, and broader subfield rule families remain deferred). -* **FILE_TYPE** — ``file_id`` rules plus Activity and Workout required - messages/fields +* **FILE_TYPE** — ``file_id`` rules plus Activity, Workout, and Course + required messages/fields * **PRESERVATION** — opt-in checks for post-edit rewrite loss (e.g. unknown field ``raw_bytes`` cleared). Not part of default / strict levels. -File-type rules are implemented for **Activity** and **Workout**. Other -``file_id.type`` values fail closed at the FILE_TYPE level (intentional until -more validators exist, e.g. Course). +File-type rules are implemented for **Activity**, **Workout**, and **Course**. +Other ``file_id.type`` values fail closed at the FILE_TYPE level (intentional +until more validators exist). """ from __future__ import annotations @@ -34,7 +34,7 @@ from fit_tool.exceptions import FitValidationError from fit_tool.field import UnknownField from fit_tool.message import Message -from fit_tool.profile.profile_type import FileType, MesgNum, WorkoutStepDuration +from fit_tool.profile.profile_type import Event, EventType, FileType, MesgNum, WorkoutStepDuration from fit_tool.record import Record if TYPE_CHECKING: @@ -50,6 +50,15 @@ IMPLEMENTED_FILE_TYPES = frozenset({ FileType.ACTIVITY.value, FileType.WORKOUT.value, + FileType.COURSE.value, +}) + +# Course timer stop event types accepted by FILE_TYPE (Garmin devices vary). +_COURSE_TIMER_STOP_TYPES = frozenset({ + EventType.STOP.value, + EventType.STOP_ALL.value, + EventType.STOP_DISABLE.value, + EventType.STOP_DISABLE_ALL.value, }) @@ -460,15 +469,14 @@ def _collect_file_type_findings( _error(findings, ConformanceLevel.FILE_TYPE, 'file_id.type is required.', file_id_index) return - _require_fields_findings( - findings, - file_id, - ('type', 'manufacturer', 'product', 'serial_number', 'time_created'), - file_id_index, - ) - message_counts = Counter(message.global_id for message in data_messages) if file_type == FileType.ACTIVITY.value: + _require_fields_findings( + findings, + file_id, + ('type', 'manufacturer', 'product', 'serial_number', 'time_created'), + file_id_index, + ) if message_counts[MesgNum.RECORD.value] < 1: _error( findings, @@ -496,6 +504,12 @@ def _collect_file_type_findings( ) _collect_activity_field_findings(data_messages, findings, data_message_indices) elif file_type == FileType.WORKOUT.value: + _require_fields_findings( + findings, + file_id, + ('type', 'manufacturer', 'product', 'serial_number', 'time_created'), + file_id_index, + ) workout_count = message_counts[MesgNum.WORKOUT.value] if workout_count != 1: _error( @@ -510,6 +524,16 @@ def _collect_file_type_findings( 'A workout FIT file requires at least one workout_step message.', ) _collect_workout_field_findings(data_messages, findings, data_message_indices) + elif file_type == FileType.COURSE.value: + # Product is optional on real Course exports (e.g. Stages Link). + _require_fields_findings( + findings, + file_id, + ('type', 'manufacturer', 'serial_number', 'time_created'), + file_id_index, + ) + _collect_course_structure_findings(data_messages, findings, message_counts) + _collect_course_field_findings(data_messages, findings, data_message_indices) else: _error( findings, @@ -662,6 +686,138 @@ def _collect_workout_field_findings( ) +def _is_course_timer_event(message: DataMessage, event_types: frozenset) -> bool: + """True when message is Event.TIMER with an event_type in ``event_types``.""" + if message.global_id != MesgNum.EVENT.value: + return False + if _enum_value(getattr(message, 'event', None)) != Event.TIMER.value: + return False + return _enum_value(getattr(message, 'event_type', None)) in event_types + + +def _collect_course_structure_findings( + data_messages: Sequence[DataMessage], + findings: list[ValidationFinding], + message_counts: Counter, +) -> None: + """Required messages for Course files (Garmin FIT Course file type). + + Must include: exactly one ``course``, ≥1 ``lap``, ≥1 ``record``, a timer + start event, and a timer stop event. ``course_point`` is optional. + """ + course_count = message_counts[MesgNum.COURSE.value] + if course_count != 1: + _error( + findings, + ConformanceLevel.FILE_TYPE, + f'A course FIT file requires exactly one course message; found {course_count}.', + ) + if message_counts[MesgNum.LAP.value] < 1: + _error( + findings, + ConformanceLevel.FILE_TYPE, + 'A course FIT file requires at least one lap message.', + ) + if message_counts[MesgNum.RECORD.value] < 1: + _error( + findings, + ConformanceLevel.FILE_TYPE, + 'A course FIT file requires at least one record message.', + ) + + start_positions = [ + pos for pos, message in enumerate(data_messages) + if _is_course_timer_event(message, frozenset({EventType.START.value})) + ] + stop_positions = [ + pos for pos, message in enumerate(data_messages) + if _is_course_timer_event(message, _COURSE_TIMER_STOP_TYPES) + ] + record_positions = [ + pos for pos, message in enumerate(data_messages) + if message.global_id == MesgNum.RECORD.value + ] + + if not start_positions: + _error( + findings, + ConformanceLevel.FILE_TYPE, + 'A course FIT file requires a timer start event message.', + ) + if not stop_positions: + _error( + findings, + ConformanceLevel.FILE_TYPE, + 'A course FIT file requires a timer stop event message.', + ) + + # Ordering: timer start before track records and before terminal timer stop + # (message order; timestamps may be equal in real Garmin exports). + if start_positions and stop_positions: + first_start = min(start_positions) + last_stop = max(stop_positions) + if first_start >= last_stop: + _error( + findings, + ConformanceLevel.FILE_TYPE, + 'A course FIT file requires the timer start event to precede ' + 'the timer stop event.', + ) + if start_positions and record_positions: + if min(start_positions) > min(record_positions): + _error( + findings, + ConformanceLevel.FILE_TYPE, + 'A course FIT file requires the timer start event to precede ' + 'track record messages.', + ) + if stop_positions and record_positions: + if max(stop_positions) < max(record_positions): + _error( + findings, + ConformanceLevel.FILE_TYPE, + 'A course FIT file requires the timer stop event to follow ' + 'track record messages.', + ) + + +def _collect_course_field_findings( + data_messages: Sequence[DataMessage], + findings: list[ValidationFinding], + data_message_indices: Mapping[int, int], +) -> None: + """Required fields on Course file messages. + + Lap requirements follow summary-message practice used by real Course + exports (timestamp / start_time / total_elapsed_time). ``total_timer_time`` + and ``message_index`` are not required — Garmin-exported courses often omit + them. Course track ``record`` messages are geometric (position) and often + omit timestamps. Course name is the ``course_name`` property (field ``name``); + ``sport`` is required Course metadata. + """ + required_fields = { + MesgNum.COURSE.value: ('course_name', 'sport'), + MesgNum.RECORD.value: ('position_lat', 'position_long'), + MesgNum.LAP.value: ( + 'timestamp', + 'start_time', + 'total_elapsed_time', + ), + MesgNum.EVENT.value: ('timestamp', 'event', 'event_type'), + } + for message_pos, message in enumerate(data_messages): + field_names = required_fields.get(message.global_id) + if field_names is not None: + _require_fields_findings( + findings, + message, + field_names, + data_message_indices.get(message_pos), + ) + + + + def _collect_preservation_findings( records: Sequence[Record], findings: list[ValidationFinding], diff --git a/news/SHA-22.feature b/news/SHA-22.feature new file mode 100644 index 0000000..553e0c3 --- /dev/null +++ b/news/SHA-22.feature @@ -0,0 +1,3 @@ +FILE_TYPE validation for Course files: required course / lap / record / timer +events and fields (aligned with Garmin Course rules and real device exports). +Workout and other non-Activity/Course types still fail closed.