deancochran-ftms 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. deancochran_ftms-0.1.0a1/.gitignore +8 -0
  2. deancochran_ftms-0.1.0a1/CHANGELOG.md +24 -0
  3. deancochran_ftms-0.1.0a1/LICENSE +21 -0
  4. deancochran_ftms-0.1.0a1/PKG-INFO +217 -0
  5. deancochran_ftms-0.1.0a1/README.md +198 -0
  6. deancochran_ftms-0.1.0a1/RELEASING.md +60 -0
  7. deancochran_ftms-0.1.0a1/pyproject.toml +83 -0
  8. deancochran_ftms-0.1.0a1/scripts/run_features_conformance.py +708 -0
  9. deancochran_ftms-0.1.0a1/scripts/run_measurement_matrix.py +152 -0
  10. deancochran_ftms-0.1.0a1/scripts/run_measurement_status_conformance.py +277 -0
  11. deancochran_ftms-0.1.0a1/scripts/verify.py +38 -0
  12. deancochran_ftms-0.1.0a1/scripts/verify_package.py +220 -0
  13. deancochran_ftms-0.1.0a1/src/deancochran_ftms/__init__.py +90 -0
  14. deancochran_ftms-0.1.0a1/src/deancochran_ftms/_binary.py +92 -0
  15. deancochran_ftms-0.1.0a1/src/deancochran_ftms/_errors.py +13 -0
  16. deancochran_ftms-0.1.0a1/src/deancochran_ftms/control.py +195 -0
  17. deancochran_ftms-0.1.0a1/src/deancochran_ftms/features.py +158 -0
  18. deancochran_ftms-0.1.0a1/src/deancochran_ftms/measurements.py +429 -0
  19. deancochran_ftms-0.1.0a1/src/deancochran_ftms/py.typed +0 -0
  20. deancochran_ftms-0.1.0a1/src/deancochran_ftms/ranges.py +175 -0
  21. deancochran_ftms-0.1.0a1/src/deancochran_ftms/statuses.py +462 -0
  22. deancochran_ftms-0.1.0a1/tests/test_binary.py +44 -0
  23. deancochran_ftms-0.1.0a1/tests/test_conformance.py +107 -0
  24. deancochran_ftms-0.1.0a1/tests/test_features.py +64 -0
  25. deancochran_ftms-0.1.0a1/tests/test_measurement_validation.py +53 -0
  26. deancochran_ftms-0.1.0a1/tests/test_measurements_statuses.py +172 -0
  27. deancochran_ftms-0.1.0a1/tests/test_ranges_control.py +182 -0
  28. deancochran_ftms-0.1.0a1/tests/test_report_integrity.py +75 -0
  29. deancochran_ftms-0.1.0a1/tests/test_status_validation.py +190 -0
  30. deancochran_ftms-0.1.0a1/uv.lock +570 -0
@@ -0,0 +1,8 @@
1
+ /build/
2
+ /dist/
3
+ /.venv/
4
+ /.mypy_cache/
5
+ /.pytest_cache/
6
+ /.ruff_cache/
7
+ __pycache__/
8
+ *.py[cod]
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ Python distribution versions are independent of FTMS specification, shared
4
+ conformance corpus, and other language-package versions.
5
+
6
+ ## 0.1.0a1
7
+
8
+ Initial partial alpha; APIs may change before a stable release.
9
+
10
+ - Pure synchronous Python with no runtime dependencies, Python >=3.11,
11
+ immutable raw models, and inline typing.
12
+ - Bidirectional raw Features, all five supported ranges, structural range
13
+ inspection, and normalized Feature/range views.
14
+ - Bidirectional raw codecs for all 21 Control Point requests and responses.
15
+ - Bidirectional measurements for treadmill, cross trainer, step climber,
16
+ stair climber, rower, and indoor bike, with normalized metric views.
17
+ - Bidirectional Training Status and Machine Status, normalized views, and
18
+ preservation of malformed/unknown decoding evidence.
19
+ - Explicit, independent range, command, and measurement wire-format options.
20
+ - Canonical shared fixture, structural matrix, and isolated package checks.
21
+
22
+ Not included: static capability evaluation, a public human-unit control encoder,
23
+ Bluetooth transport, device lifecycle, retries, execution permission, or safety
24
+ policy. Host tests are not device interoperability or Bluetooth qualification.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dean Cochran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.5
2
+ Name: deancochran-ftms
3
+ Version: 0.1.0a1
4
+ Summary: Pure Python FTMS feature, range, control, measurement, and status codecs (alpha)
5
+ Project-URL: Source, https://github.com/deancochran/ftms
6
+ Project-URL: Issues, https://github.com/deancochran/ftms/issues
7
+ Project-URL: Documentation, https://github.com/deancochran/ftms/blob/python-v0.1.0a1/packages/python/README.md
8
+ Author: Dean Cochran
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+
20
+ # deancochran-ftms
21
+
22
+ `deancochran-ftms` is a pure, synchronous Python protocol package. This
23
+ **0.1.0a1 is a partial alpha release with an evolving API**: it implements FTMS Features, all five supported ranges (raw and normalized), structural range inspection, raw Control Point requests/responses, all six raw measurement families, and bidirectional Machine/Training Status. It has no BLE, lifecycle, logging, or capability APIs. It is not a complete port of every API in the TypeScript/C packages.
24
+
25
+ ## Install and compatibility
26
+
27
+ The distribution name is `deancochran-ftms`; import `deancochran_ftms`. It has
28
+ no runtime dependencies and declares Python >=3.11. Python 3.11 and 3.14 are
29
+ tested by the package verification commands in this milestone.
30
+
31
+ Install this explicitly selected prerelease from PyPI:
32
+
33
+ ```sh
34
+ python -m pip install 'deancochran-ftms==0.1.0a1'
35
+ ```
36
+
37
+ ```python
38
+ from deancochran_ftms import decode_features, encode_features_raw, FeaturesRaw
39
+
40
+ wire = encode_features_raw(FeaturesRaw(machine=0, target=1 << 3))
41
+ result = decode_features(wire)
42
+ assert result.ok and result.value is not None
43
+ assert result.value.power_target_setting_supported
44
+ ```
45
+
46
+ `decode_features_raw()` and `encode_features_raw()` are strict wire codecs and
47
+ raise `RawCodecError` for wrong byte inputs, non-8-byte payloads, or invalid raw
48
+ words. `decode_features()` instead returns an immutable `FeatureDecodeResult`:
49
+ wrong lengths produce a `FeatureDiagnostic` with `code="length"`. Feature
50
+ payloads are exactly eight bytes; trailing bytes are rejected, matching the
51
+ canonical TypeScript Feature behavior. `bytes`, `bytearray`, and contiguous
52
+ one-dimensional byte `memoryview` inputs are accepted and copied as immutable
53
+ evidence. Raw words retain all unknown/reserved bits. Integer raw words must be
54
+ plain `int` values from 0 through 2^32-1; `bool` is rejected.
55
+
56
+ The normalized `Features` model exposes the canonical v1 feature names in
57
+ snake_case. Its three convenience properties (`supports_erg`, `supports_sim`,
58
+ and `supports_resistance`) correspond to the v1 compatibility names.
59
+
60
+ ## Ranges and inspection
61
+
62
+ `decode_supported_range_raw(data, kind, options=None)` and
63
+ `encode_supported_range_raw(value, options=None)` operate on `SupportedRangeRaw`.
64
+ Kinds are `speed`, `inclination`, `resistance`, `heartRate`, and `power`.
65
+ Values retain integer numerators, `scale_divisor`, and units; the normalized
66
+ `decode_supported_range()` divides these values into physical units. All three
67
+ raise `RawCodecError` for invalid arguments, lengths, or range values.
68
+
69
+ Resistance ranges default to three unsigned whole-level bytes. Select the
70
+ six-byte signed-tenths alternative explicitly with
71
+ `RangeFormatOptions(resistance_format="signed16Tenths")`; this option is invalid
72
+ for other range kinds. Range units do not imply resistance percentages.
73
+
74
+ `inspect_supported_range_raw()` returns the shared inspection report shape:
75
+ selected profile, actual/expected lengths, status, selected value, and ordered
76
+ structural candidates. Malformed wire lengths/values appear as candidate statuses;
77
+ invalid caller kinds/options still raise. Report values use the canonical numeric
78
+ unit identifiers (0 speed, 1 inclination, 2 resistance, 3 heart rate, 4 power).
79
+ The returned dictionary is caller-owned; selected and candidate values do not
80
+ alias. A valid alternative candidate never selects a profile automatically or
81
+ establishes physical units on a particular machine.
82
+
83
+ ## Control Point requests and responses
84
+
85
+ ```python
86
+ from deancochran_ftms import (
87
+ ControlRequestRaw,
88
+ decode_control_request_raw,
89
+ encode_control_request_raw,
90
+ decode_control_response_raw,
91
+ )
92
+
93
+ # Set Target Power: opcode 0x05, raw operand in watts. This does not send anything.
94
+ wire = encode_control_request_raw(ControlRequestRaw(0x05, (75,)))
95
+ assert wire == bytes.fromhex("05 4b 00")
96
+ assert decode_control_request_raw(wire).operands == (75,)
97
+ response = decode_control_response_raw(bytes.fromhex("80 05 01"))
98
+ assert response.request_opcode == 5 and response.result_code == 1
99
+ ```
100
+
101
+ All 21 request opcodes support raw encode/decode. Operands must be tuples of plain
102
+ integers in wire units, not arbitrary human-unit floats. For example simulation
103
+ operands are wind speed in thousandths of m/s, grade in hundredths of percent,
104
+ rolling coefficient in ten-thousandths, and wind coefficient in hundredths kg/m.
105
+ There is no public normalized control encoder in this milestone; the legacy
106
+ codec-v1 test adapter translates its normalized fixtures to raw operands.
107
+
108
+ `ControlFormatOptions(resistance_format="uint8Tenths")` explicitly selects the
109
+ alternative resistance command width; the default is `signed16Tenths`. Control
110
+ options are independent of range options and do not change other opcodes.
111
+
112
+ `ControlResponseRaw` preserves request/result codes plus integer diagnostic flags
113
+ `unknown_request`, `unknown_result`, and `unexpected_parameters`. Its `parameter`
114
+ is 0 for none or 1 for successful spin-down speeds (`low`/`high` in hundredths
115
+ km/h). `encode_control_response_raw()` accepts only canonical response forms;
116
+ decodable malformed/unknown evidence is not necessarily encodable. Invalid wire
117
+ headers/lengths and invalid encoder arguments raise `RawCodecError`.
118
+
119
+ No codec acquires control, sends bytes, retries commands, or authorizes execution.
120
+
121
+ ## Development verification
122
+
123
+ From `packages/python/` in a full repository checkout (with `uv` installed):
124
+
125
+ ```sh
126
+ uv run --locked --group dev python scripts/verify.py
127
+ ```
128
+
129
+ That aggregate command runs both supported test interpreters, quality checks,
130
+ all codec corpus runners, the structural matrix, and isolated installation.
131
+ Individual checks are also available:
132
+
133
+ ```sh
134
+ uv run --locked --group dev pytest
135
+ uv run --locked --group dev ruff format --check .
136
+ uv run --locked --group dev ruff check .
137
+ uv run --locked --group dev mypy src tests scripts
138
+ uv run --locked --group dev python scripts/run_features_conformance.py
139
+ uv run --locked --group dev python scripts/run_measurement_status_conformance.py
140
+ uv run --locked --group dev python scripts/run_measurement_matrix.py
141
+ uv run --locked --group dev python scripts/verify_package.py
142
+ ```
143
+
144
+ The conformance test reads `../../shared/conformance/v1` and
145
+ `../../shared/conformance/values/v1` directly; it never copies fixtures.
146
+ It executes all codec-v1 categories and the additive values, controls,
147
+ inspection, compatibility, measurements and statuses contracts directly from
148
+ shared fixtures. Capability interpretation is not implemented or claimed.
149
+ `scripts/verify_package.py` builds both artifacts, rebuilds from
150
+ an extracted sdist outside this checkout, installs the wheel non-editably into
151
+ a fresh environment, and writes its distinct machine-readable artifact hashes,
152
+ interpreter, and per-step install/rebuild evidence to ignored
153
+ `build/package-verification-report.json`. Conformance identity and case evidence
154
+ are separately recorded in `build/verification-report.json` and
155
+ `build/measurement-status-verification-report.json`. The structural matrix
156
+ records 181,760 layouts in both directions, 46 sentinel cases, 47 RFU cases,
157
+ and 315 incomplete prefixes in `build/measurement-matrix-verification-report.json`.
158
+ These generated cases are not separate pytest test registrations. The sdist
159
+ intentionally contains no shared corpus dependency: installing or building the
160
+ package needs no checkout, but running the conformance suite does. The package
161
+ check asserts that rebuilding the extracted sdist produces an identical wheel;
162
+ it does not assert byte-for-byte reproducibility of the sdist archive itself.
163
+
164
+ This is host protocol regression evidence only, not live control execution, device
165
+ interoperability or Bluetooth qualification. Public availability is separate
166
+ from these host verification results.
167
+
168
+ ## Coverage and limits
169
+
170
+ Version `0.1.0a1` is an **alpha with partial API coverage**. Features, ranges, controls,
171
+ all six measurement families and both status characteristics have raw codecs.
172
+ Normalized measurement/status projections and normalized range decoding are
173
+ available. Capability evaluation remains outstanding. Range, control and
174
+ measurement format choices are independent and explicit; no API infers them
175
+ from packet length, feature declarations or BLE state. Host fixtures establish
176
+ codec regression evidence, not live-device compatibility or qualification.
177
+
178
+ ## Measurements and statuses
179
+
180
+ `MeasurementRaw` is immutable raw evidence: `flags`, 30 wire-unit `values`, and
181
+ `present`/`unavailable` bit masks retain field-level availability. Use
182
+ `decode_measurement_raw(data, kind, options=None)` and
183
+ `encode_measurement_raw(value, options=None)`. Kinds are 0 Treadmill, 1 Cross
184
+ Trainer, 2 Step Climber, 3 Stair Climber, 4 Rower, and 5 Indoor Bike. Decoders
185
+ retain More Data, Cross Trainer backward direction, RFU, truncation, and trailing
186
+ byte diagnostics. Encoders validate the wire flags, presence/unavailable masks
187
+ and selected values; auxiliary decode diagnostics do not themselves authorize
188
+ or prevent encoding, matching the shared raw contract. Compatibility formats
189
+ are explicit only: `MeasurementFormatOptions(resistance_format="signed16Tenths")`
190
+ and `treadmill_pace_format="uint8Legacy"`; they are independent selections and
191
+ are never inferred from packet length, features, or machine identity.
192
+
193
+ ```python
194
+ from deancochran_ftms import MeasurementRaw, decode_measurement_raw, encode_measurement_raw
195
+
196
+ raw = MeasurementRaw(5, 0, 1, 0, (1234,) + (0,) * 29) # speed: 12.34 km/h
197
+ assert encode_measurement_raw(raw) == bytes.fromhex("00 00 d2 04")
198
+ assert decode_measurement_raw(bytes.fromhex("00 00 d2 04"), 5).values[0] == 1234
199
+ ```
200
+
201
+ Machine Status uses `MachineStatusRaw` with raw opcode/action and optional
202
+ `(control_opcode, operands)` parameter tuple. Training Status uses
203
+ `TrainingStatusRaw`; text is UTF-8 `bytes`, so malformed UTF-8 is retained and
204
+ diagnosed by `invalid_utf8` rather than silently replaced. The corresponding
205
+ `decode_*_status_raw` APIs preserve partial/trailing/reserved evidence; encoders
206
+ accept only canonical values and raise `RawCodecError` on invalid widths, flags,
207
+ UTF-8, sentinels, or diagnostics. Raw units/scales remain FTMS wire units.
208
+
209
+ Host tests cover Python 3.11 and 3.14 plus shared literal measurement/status
210
+ corpora. This finite evidence is not BLE, real-device, PTS, or Bluetooth
211
+ qualification evidence.
212
+
213
+ `normalize_measurement(raw, options=None)` provides the immutable-raw-to-codec-v1
214
+ normalized metric projection (for example raw speed / 360 to m/s, cadence / 2,
215
+ and unavailable or incomplete fields as `None`). `normalize_machine_status()` and
216
+ `normalize_training_status()` provide the corresponding normalized status views;
217
+ raw codecs remain the source of wire evidence.
@@ -0,0 +1,198 @@
1
+ # deancochran-ftms
2
+
3
+ `deancochran-ftms` is a pure, synchronous Python protocol package. This
4
+ **0.1.0a1 is a partial alpha release with an evolving API**: it implements FTMS Features, all five supported ranges (raw and normalized), structural range inspection, raw Control Point requests/responses, all six raw measurement families, and bidirectional Machine/Training Status. It has no BLE, lifecycle, logging, or capability APIs. It is not a complete port of every API in the TypeScript/C packages.
5
+
6
+ ## Install and compatibility
7
+
8
+ The distribution name is `deancochran-ftms`; import `deancochran_ftms`. It has
9
+ no runtime dependencies and declares Python >=3.11. Python 3.11 and 3.14 are
10
+ tested by the package verification commands in this milestone.
11
+
12
+ Install this explicitly selected prerelease from PyPI:
13
+
14
+ ```sh
15
+ python -m pip install 'deancochran-ftms==0.1.0a1'
16
+ ```
17
+
18
+ ```python
19
+ from deancochran_ftms import decode_features, encode_features_raw, FeaturesRaw
20
+
21
+ wire = encode_features_raw(FeaturesRaw(machine=0, target=1 << 3))
22
+ result = decode_features(wire)
23
+ assert result.ok and result.value is not None
24
+ assert result.value.power_target_setting_supported
25
+ ```
26
+
27
+ `decode_features_raw()` and `encode_features_raw()` are strict wire codecs and
28
+ raise `RawCodecError` for wrong byte inputs, non-8-byte payloads, or invalid raw
29
+ words. `decode_features()` instead returns an immutable `FeatureDecodeResult`:
30
+ wrong lengths produce a `FeatureDiagnostic` with `code="length"`. Feature
31
+ payloads are exactly eight bytes; trailing bytes are rejected, matching the
32
+ canonical TypeScript Feature behavior. `bytes`, `bytearray`, and contiguous
33
+ one-dimensional byte `memoryview` inputs are accepted and copied as immutable
34
+ evidence. Raw words retain all unknown/reserved bits. Integer raw words must be
35
+ plain `int` values from 0 through 2^32-1; `bool` is rejected.
36
+
37
+ The normalized `Features` model exposes the canonical v1 feature names in
38
+ snake_case. Its three convenience properties (`supports_erg`, `supports_sim`,
39
+ and `supports_resistance`) correspond to the v1 compatibility names.
40
+
41
+ ## Ranges and inspection
42
+
43
+ `decode_supported_range_raw(data, kind, options=None)` and
44
+ `encode_supported_range_raw(value, options=None)` operate on `SupportedRangeRaw`.
45
+ Kinds are `speed`, `inclination`, `resistance`, `heartRate`, and `power`.
46
+ Values retain integer numerators, `scale_divisor`, and units; the normalized
47
+ `decode_supported_range()` divides these values into physical units. All three
48
+ raise `RawCodecError` for invalid arguments, lengths, or range values.
49
+
50
+ Resistance ranges default to three unsigned whole-level bytes. Select the
51
+ six-byte signed-tenths alternative explicitly with
52
+ `RangeFormatOptions(resistance_format="signed16Tenths")`; this option is invalid
53
+ for other range kinds. Range units do not imply resistance percentages.
54
+
55
+ `inspect_supported_range_raw()` returns the shared inspection report shape:
56
+ selected profile, actual/expected lengths, status, selected value, and ordered
57
+ structural candidates. Malformed wire lengths/values appear as candidate statuses;
58
+ invalid caller kinds/options still raise. Report values use the canonical numeric
59
+ unit identifiers (0 speed, 1 inclination, 2 resistance, 3 heart rate, 4 power).
60
+ The returned dictionary is caller-owned; selected and candidate values do not
61
+ alias. A valid alternative candidate never selects a profile automatically or
62
+ establishes physical units on a particular machine.
63
+
64
+ ## Control Point requests and responses
65
+
66
+ ```python
67
+ from deancochran_ftms import (
68
+ ControlRequestRaw,
69
+ decode_control_request_raw,
70
+ encode_control_request_raw,
71
+ decode_control_response_raw,
72
+ )
73
+
74
+ # Set Target Power: opcode 0x05, raw operand in watts. This does not send anything.
75
+ wire = encode_control_request_raw(ControlRequestRaw(0x05, (75,)))
76
+ assert wire == bytes.fromhex("05 4b 00")
77
+ assert decode_control_request_raw(wire).operands == (75,)
78
+ response = decode_control_response_raw(bytes.fromhex("80 05 01"))
79
+ assert response.request_opcode == 5 and response.result_code == 1
80
+ ```
81
+
82
+ All 21 request opcodes support raw encode/decode. Operands must be tuples of plain
83
+ integers in wire units, not arbitrary human-unit floats. For example simulation
84
+ operands are wind speed in thousandths of m/s, grade in hundredths of percent,
85
+ rolling coefficient in ten-thousandths, and wind coefficient in hundredths kg/m.
86
+ There is no public normalized control encoder in this milestone; the legacy
87
+ codec-v1 test adapter translates its normalized fixtures to raw operands.
88
+
89
+ `ControlFormatOptions(resistance_format="uint8Tenths")` explicitly selects the
90
+ alternative resistance command width; the default is `signed16Tenths`. Control
91
+ options are independent of range options and do not change other opcodes.
92
+
93
+ `ControlResponseRaw` preserves request/result codes plus integer diagnostic flags
94
+ `unknown_request`, `unknown_result`, and `unexpected_parameters`. Its `parameter`
95
+ is 0 for none or 1 for successful spin-down speeds (`low`/`high` in hundredths
96
+ km/h). `encode_control_response_raw()` accepts only canonical response forms;
97
+ decodable malformed/unknown evidence is not necessarily encodable. Invalid wire
98
+ headers/lengths and invalid encoder arguments raise `RawCodecError`.
99
+
100
+ No codec acquires control, sends bytes, retries commands, or authorizes execution.
101
+
102
+ ## Development verification
103
+
104
+ From `packages/python/` in a full repository checkout (with `uv` installed):
105
+
106
+ ```sh
107
+ uv run --locked --group dev python scripts/verify.py
108
+ ```
109
+
110
+ That aggregate command runs both supported test interpreters, quality checks,
111
+ all codec corpus runners, the structural matrix, and isolated installation.
112
+ Individual checks are also available:
113
+
114
+ ```sh
115
+ uv run --locked --group dev pytest
116
+ uv run --locked --group dev ruff format --check .
117
+ uv run --locked --group dev ruff check .
118
+ uv run --locked --group dev mypy src tests scripts
119
+ uv run --locked --group dev python scripts/run_features_conformance.py
120
+ uv run --locked --group dev python scripts/run_measurement_status_conformance.py
121
+ uv run --locked --group dev python scripts/run_measurement_matrix.py
122
+ uv run --locked --group dev python scripts/verify_package.py
123
+ ```
124
+
125
+ The conformance test reads `../../shared/conformance/v1` and
126
+ `../../shared/conformance/values/v1` directly; it never copies fixtures.
127
+ It executes all codec-v1 categories and the additive values, controls,
128
+ inspection, compatibility, measurements and statuses contracts directly from
129
+ shared fixtures. Capability interpretation is not implemented or claimed.
130
+ `scripts/verify_package.py` builds both artifacts, rebuilds from
131
+ an extracted sdist outside this checkout, installs the wheel non-editably into
132
+ a fresh environment, and writes its distinct machine-readable artifact hashes,
133
+ interpreter, and per-step install/rebuild evidence to ignored
134
+ `build/package-verification-report.json`. Conformance identity and case evidence
135
+ are separately recorded in `build/verification-report.json` and
136
+ `build/measurement-status-verification-report.json`. The structural matrix
137
+ records 181,760 layouts in both directions, 46 sentinel cases, 47 RFU cases,
138
+ and 315 incomplete prefixes in `build/measurement-matrix-verification-report.json`.
139
+ These generated cases are not separate pytest test registrations. The sdist
140
+ intentionally contains no shared corpus dependency: installing or building the
141
+ package needs no checkout, but running the conformance suite does. The package
142
+ check asserts that rebuilding the extracted sdist produces an identical wheel;
143
+ it does not assert byte-for-byte reproducibility of the sdist archive itself.
144
+
145
+ This is host protocol regression evidence only, not live control execution, device
146
+ interoperability or Bluetooth qualification. Public availability is separate
147
+ from these host verification results.
148
+
149
+ ## Coverage and limits
150
+
151
+ Version `0.1.0a1` is an **alpha with partial API coverage**. Features, ranges, controls,
152
+ all six measurement families and both status characteristics have raw codecs.
153
+ Normalized measurement/status projections and normalized range decoding are
154
+ available. Capability evaluation remains outstanding. Range, control and
155
+ measurement format choices are independent and explicit; no API infers them
156
+ from packet length, feature declarations or BLE state. Host fixtures establish
157
+ codec regression evidence, not live-device compatibility or qualification.
158
+
159
+ ## Measurements and statuses
160
+
161
+ `MeasurementRaw` is immutable raw evidence: `flags`, 30 wire-unit `values`, and
162
+ `present`/`unavailable` bit masks retain field-level availability. Use
163
+ `decode_measurement_raw(data, kind, options=None)` and
164
+ `encode_measurement_raw(value, options=None)`. Kinds are 0 Treadmill, 1 Cross
165
+ Trainer, 2 Step Climber, 3 Stair Climber, 4 Rower, and 5 Indoor Bike. Decoders
166
+ retain More Data, Cross Trainer backward direction, RFU, truncation, and trailing
167
+ byte diagnostics. Encoders validate the wire flags, presence/unavailable masks
168
+ and selected values; auxiliary decode diagnostics do not themselves authorize
169
+ or prevent encoding, matching the shared raw contract. Compatibility formats
170
+ are explicit only: `MeasurementFormatOptions(resistance_format="signed16Tenths")`
171
+ and `treadmill_pace_format="uint8Legacy"`; they are independent selections and
172
+ are never inferred from packet length, features, or machine identity.
173
+
174
+ ```python
175
+ from deancochran_ftms import MeasurementRaw, decode_measurement_raw, encode_measurement_raw
176
+
177
+ raw = MeasurementRaw(5, 0, 1, 0, (1234,) + (0,) * 29) # speed: 12.34 km/h
178
+ assert encode_measurement_raw(raw) == bytes.fromhex("00 00 d2 04")
179
+ assert decode_measurement_raw(bytes.fromhex("00 00 d2 04"), 5).values[0] == 1234
180
+ ```
181
+
182
+ Machine Status uses `MachineStatusRaw` with raw opcode/action and optional
183
+ `(control_opcode, operands)` parameter tuple. Training Status uses
184
+ `TrainingStatusRaw`; text is UTF-8 `bytes`, so malformed UTF-8 is retained and
185
+ diagnosed by `invalid_utf8` rather than silently replaced. The corresponding
186
+ `decode_*_status_raw` APIs preserve partial/trailing/reserved evidence; encoders
187
+ accept only canonical values and raise `RawCodecError` on invalid widths, flags,
188
+ UTF-8, sentinels, or diagnostics. Raw units/scales remain FTMS wire units.
189
+
190
+ Host tests cover Python 3.11 and 3.14 plus shared literal measurement/status
191
+ corpora. This finite evidence is not BLE, real-device, PTS, or Bluetooth
192
+ qualification evidence.
193
+
194
+ `normalize_measurement(raw, options=None)` provides the immutable-raw-to-codec-v1
195
+ normalized metric projection (for example raw speed / 360 to m/s, cadence / 2,
196
+ and unavailable or incomplete fields as `None`). `normalize_machine_status()` and
197
+ `normalize_training_status()` provide the corresponding normalized status views;
198
+ raw codecs remain the source of wire evidence.
@@ -0,0 +1,60 @@
1
+ # Python alpha release
2
+
3
+ Publishing requires explicit authorization. The distribution is `deancochran-ftms`;
4
+ `pyproject.toml` defines its independently versioned Python release. The current
5
+ `0.1.0a1` is a partial alpha, not full cross-language API parity or device evidence.
6
+
7
+ ## One-time Trusted Publishing setup
8
+
9
+ At <https://pypi.org/manage/account/publishing/>, add a **pending publisher**
10
+ (the first successful upload creates the project):
11
+
12
+ | Field | Value |
13
+ | --- | --- |
14
+ | PyPI project name | `deancochran-ftms` |
15
+ | GitHub owner | `deancochran` |
16
+ | Repository | `ftms` |
17
+ | Workflow filename | `release-python.yml` |
18
+ | Environment | `pypi` |
19
+
20
+ The GitHub repository's `pypi` environment must permit only `python-v*` **tags**
21
+ and require approval by `deancochran` before publishing. Repository administrators
22
+ must not bypass that approval. Tag restrictions alone are not an approval gate.
23
+ No long-lived PyPI token or GitHub secret is needed. Registering a pending
24
+ publisher does not reserve the package name or publish anything.
25
+
26
+ ## Authorized release procedure
27
+
28
+ 1. Verify the scope, changelog, version and documentation. Keep capability
29
+ interpretation and other unimplemented APIs explicitly outside this alpha.
30
+ 2. Commit the reviewed source. From a clean checkout at that exact commit,
31
+ run `uv run --locked --group dev python scripts/verify.py` in `packages/python/`.
32
+ Inspect reports, source identity, clean state and archive contents. Then run:
33
+ `uv tool run --from twine==7.0.0 twine check --strict build/isolated/*.whl build/isolated/*.tar.gz`.
34
+ 3. Push the source branch and require the Python workflow's verification job to
35
+ pass. A branch push or pull request cannot execute the publishing job.
36
+ 4. Confirm the PyPI publisher setup and explicit release authorization before
37
+ creating and pushing the immutable `python-vVERSION` tag at that reviewed
38
+ commit. Python tags are independent of `v*` (npm) and `c-v*` tags.
39
+ The initial alpha can be tagged on the reviewed Python branch; this does not
40
+ merge that branch to `main` or authorize merging other ports.
41
+ 5. The tag workflow verifies the version/tag match, both test interpreters,
42
+ all scoped codec corpora, the structural matrix, and the isolated rebuilt-wheel
43
+ consumer. It checks metadata and saves distributions plus identity/hash reports.
44
+ 6. Approve the publishing deployment only after reviewing that exact tag/commit
45
+ and its verification job. A separate job, restricted to the `pypi` environment, checks the distribution
46
+ hashes and publishes those exact artifacts using OIDC and attestations. Only
47
+ this job receives `id-token: write`; it does not check out or build source.
48
+ 7. Confirm successful workflow completion, query the exact PyPI version, compare
49
+ both public SHA-256 digests to the workflow artifacts, and perform a fresh
50
+ non-editable registry installation. Only then describe the version as published.
51
+
52
+ The workflow deliberately fails on duplicate uploads; do not overwrite or move a
53
+ release tag. On a partial upload or network failure, inspect PyPI and compare
54
+ existing artifact digests before deciding how to recover. Never bypass a failed
55
+ verification gate or claim publication from a pushed tag alone.
56
+
57
+ The source archive is installable without shared fixtures. Full repository
58
+ verification requires a checkout containing the canonical `shared/conformance/`
59
+ assets. The package verifier asserts identical wheel bytes after an extracted-sdist
60
+ rebuild; it does not claim byte-identical source archives after that rebuild.
@@ -0,0 +1,83 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "deancochran-ftms"
7
+ version = "0.1.0a1"
8
+ description = "Pure Python FTMS feature, range, control, measurement, and status codecs (alpha)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Dean Cochran" }]
14
+ classifiers = [
15
+ "Development Status :: 2 - Pre-Alpha",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Source = "https://github.com/deancochran/ftms"
26
+ Issues = "https://github.com/deancochran/ftms/issues"
27
+ Documentation = "https://github.com/deancochran/ftms/blob/python-v0.1.0a1/packages/python/README.md"
28
+
29
+ [dependency-groups]
30
+ dev = ["jsonschema>=4.23", "mypy>=1.13", "pytest>=8.3", "ruff>=0.8", "types-jsonschema>=4.23"]
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/deancochran_ftms"]
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ only-include = [
37
+ ".gitignore",
38
+ "CHANGELOG.md",
39
+ "LICENSE",
40
+ "README.md",
41
+ "RELEASING.md",
42
+ "pyproject.toml",
43
+ "uv.lock",
44
+ "src/deancochran_ftms/__init__.py",
45
+ "src/deancochran_ftms/_binary.py",
46
+ "src/deancochran_ftms/_errors.py",
47
+ "src/deancochran_ftms/control.py",
48
+ "src/deancochran_ftms/features.py",
49
+ "src/deancochran_ftms/measurements.py",
50
+ "src/deancochran_ftms/py.typed",
51
+ "src/deancochran_ftms/ranges.py",
52
+ "src/deancochran_ftms/statuses.py",
53
+ "scripts/run_features_conformance.py",
54
+ "scripts/run_measurement_matrix.py",
55
+ "scripts/run_measurement_status_conformance.py",
56
+ "scripts/verify.py",
57
+ "scripts/verify_package.py",
58
+ "tests/test_binary.py",
59
+ "tests/test_conformance.py",
60
+ "tests/test_features.py",
61
+ "tests/test_measurement_validation.py",
62
+ "tests/test_measurements_statuses.py",
63
+ "tests/test_ranges_control.py",
64
+ "tests/test_report_integrity.py",
65
+ "tests/test_status_validation.py",
66
+ ]
67
+
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
70
+ addopts = ["--import-mode=importlib"]
71
+
72
+ [tool.ruff]
73
+ target-version = "py311"
74
+ line-length = 100
75
+
76
+ [tool.ruff.lint]
77
+ select = ["E", "F", "I", "UP"]
78
+ ignore = ["E501", "E702"]
79
+
80
+ [tool.mypy]
81
+ python_version = "3.11"
82
+ strict = true
83
+ files = ["src", "tests", "scripts"]