coospo-cli 0.1.0__tar.gz → 0.2.0__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 (33) hide show
  1. coospo_cli-0.2.0/MANIFEST.in +3 -0
  2. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/PKG-INFO +29 -4
  3. coospo_cli-0.2.0/PROTOCOL.md +236 -0
  4. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/README.md +27 -2
  5. coospo_cli-0.2.0/contrib/config.toml +22 -0
  6. coospo_cli-0.2.0/contrib/openrc/coospo +24 -0
  7. coospo_cli-0.2.0/contrib/openrc/coospo.confd +4 -0
  8. coospo_cli-0.2.0/contrib/strava-upload +200 -0
  9. coospo_cli-0.2.0/contrib/strava.json +7 -0
  10. coospo_cli-0.2.0/contrib/systemd/coospo.service +15 -0
  11. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/pyproject.toml +2 -2
  12. coospo_cli-0.2.0/src/coospo/cli.py +194 -0
  13. coospo_cli-0.2.0/src/coospo/config.py +41 -0
  14. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo/device.py +31 -8
  15. coospo_cli-0.2.0/src/coospo/fit.py +33 -0
  16. coospo_cli-0.2.0/src/coospo/index.py +87 -0
  17. coospo_cli-0.2.0/src/coospo/sync.py +112 -0
  18. coospo_cli-0.2.0/src/coospo/watch.py +45 -0
  19. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/PKG-INFO +29 -4
  20. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/SOURCES.txt +16 -1
  21. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/tests/test_protocol.py +1 -1
  22. coospo_cli-0.2.0/tests/test_sync.py +202 -0
  23. coospo_cli-0.2.0/tools/gatt_dump.py +63 -0
  24. coospo_cli-0.1.0/src/coospo/cli.py +0 -140
  25. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/LICENSE +0 -0
  26. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/setup.cfg +0 -0
  27. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo/__init__.py +0 -0
  28. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo/__main__.py +0 -0
  29. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo/protocol.py +0 -0
  30. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/dependency_links.txt +0 -0
  31. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/entry_points.txt +0 -0
  32. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/requires.txt +0 -0
  33. {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/top_level.txt +0 -0
@@ -0,0 +1,3 @@
1
+ include PROTOCOL.md
2
+ recursive-include contrib *
3
+ recursive-include tools *.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coospo-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Command-line tool for COOSPO CS500/CS600 bike computers: device info, battery, memory usage and FIT file download over Bluetooth LE
5
5
  License-Expression: GPL-3.0-or-later
6
6
  Project-URL: Homepage, https://github.com/blshkv/coospo-cli
@@ -14,7 +14,7 @@ Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Topic :: System :: Hardware
16
16
  Classifier: Topic :: Utilities
17
- Requires-Python: >=3.9
17
+ Requires-Python: >=3.13
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
20
  Requires-Dist: bleak>=0.22
@@ -28,7 +28,7 @@ CoospoRide app.
28
28
 
29
29
  ```
30
30
  $ coospo info
31
- Searching for CS500 or CS600...
31
+ Searching for Coospo compatible device...
32
32
  Connected to CS500-1234567
33
33
  Model: CS500
34
34
  Serial: 1234567
@@ -53,7 +53,7 @@ If you own a CS600 or another model, please report whether it works in an
53
53
 
54
54
  ## Installation
55
55
 
56
- Requires Python 3.9+ and a Bluetooth LE adapter. It uses [bleak](https://github.com/hbldh/bleak), so
56
+ Requires Python 3.13+ and a Bluetooth LE adapter. It uses [bleak](https://github.com/hbldh/bleak), so
57
57
  it should run on Linux (BlueZ), Windows and macOS; so far it's been tested on Linux.
58
58
 
59
59
  ```
@@ -120,6 +120,31 @@ what the official app does:
120
120
  your location, device addresses and ride history. Describe what you did and share the relevant
121
121
  frames instead.
122
122
 
123
+ ## Automatic sync
124
+
125
+ `coospo sync` downloads the activities the index (`~/.local/state/coospo/index.db`) does not know
126
+ yet. `coospo watch` does the same whenever the device comes into range, at most once per `--interval`
127
+ (default 10 minutes). Both can pass each new activity to a command, e.g. an uploader:
128
+
129
+ ```
130
+ coospo init # once: ignore the rides already on the device
131
+ coospo watch --post-sync ~/bin/strava-upload
132
+ ```
133
+
134
+ `contrib/strava-upload` is such an uploader for Strava (standard library only). Create an API
135
+ application at strava.com/settings/api with the callback domain `localhost`, copy the script to
136
+ `~/bin`, and run `strava-upload auth` once.
137
+
138
+ - The command is run as `CMD FILE`, with `COOSPO_FILE`, `COOSPO_SERIAL` and `COOSPO_ACTIVITY_TIME`
139
+ in the environment. If it exits non-zero it is retried with growing delays (1 min up to 6 h).
140
+ - Downloaded files are deleted once the command succeeds. With `--output DIR` they are kept there.
141
+ Without `--post-sync`, files are always kept (default `~/.local/share/coospo/fit_files`).
142
+ - A file that is still being recorded is not a complete FIT file; it is skipped and fetched again later.
143
+ - Settings can go in `~/.config/coospo/config.toml` (or `$XDG_CONFIG_HOME/coospo/config.toml`, or
144
+ the file given with `-c`): `name`, `address`, `scan_timeout`, `output`, `post_sync`, `interval`.
145
+ See `contrib/config.toml` for a commented sample.
146
+ - `contrib/` has a systemd user unit and an OpenRC init script that run `coospo watch`.
147
+
123
148
  ## Development
124
149
 
125
150
  ```
@@ -0,0 +1,236 @@
1
+ # COOSPO CS500 BLE file-transfer protocol
2
+
3
+ Reverse-engineered notes for downloading files (FIT activities, `filelist.txt`, `Setting.json`) from a
4
+ COOSPO CS500 bike computer over Bluetooth LE, without the CoospoRide app.
5
+
6
+ - Source: an Android HCI snoop log of CoospoRide syncing with the device, plus live tests with
7
+ [bleak](https://github.com/hbldh/bleak) on Linux/BlueZ.
8
+ - Tested device: CS500, hardware V1.41, firmware V1.5.0.
9
+ - Reference implementation: the `coospo` package in this repository (`src/coospo/`, Python 3.9+,
10
+ bleak).
11
+ - All example frames below are re-encoded with made-up values (serial number, timestamps, file names),
12
+ but they have valid lengths and CRCs.
13
+
14
+ Status legend: **confirmed** = seen in the capture and exercised by the reference implementation;
15
+ **observed** = seen in the capture only; **unknown** = not understood yet.
16
+
17
+ ## 1. GATT layout
18
+
19
+ The device advertises as `CS500-<serial>`. It does **not** expose the Nordic UART service
20
+ (`6e400001-b5a3-f393-e0a9-e50e24dcca9e`) used by XOSS/Cycplus/older COOSPO devices, and it does not
21
+ answer their YMODEM-style commands.
22
+
23
+ | Service | Characteristic | Properties | Use |
24
+ |---|---|---|---|
25
+ | `0xFDA0` | `fda1` | write, write-no-rsp, notify | requests / responses |
26
+ | `0xFDA0` | `fda2` | write, write-no-rsp, notify | commands and their acks |
27
+ | `0xFDA0` | `fda3` | write, write-no-rsp, notify | device events and their acks |
28
+ | `0xFDA0` | `fda4` | write, write-no-rsp, notify | raw file data (device → app) |
29
+ | `0xFD00` | `fd09` / `fd0a` | notify / write-no-rsp | separate protocol, see §7 |
30
+ | `0x180A`, `0x180F` | standard | read | Device Information, Battery |
31
+
32
+ The app enables notifications on `fda1`–`fda4` and `fd09`, then writes all frames with
33
+ write-without-response. The protocol works at any ATT MTU; data packets simply get shorter at
34
+ smaller MTUs.
35
+
36
+ ### 1.1 Link layer — observed
37
+
38
+ What the CoospoRide session (Android phone as central) showed below the GATT level:
39
+
40
+ | Item | Observed |
41
+ |---|---|
42
+ | ATT MTU | the **device** starts the MTU exchange requesting 200; the phone offers 517, so 200 is used |
43
+ | LE features of the device | mask `0x4925`: LE Data Packet Length Extension, LE 2M PHY and LE Coded PHY are supported |
44
+ | Data Length Extension | requested by the phone; afterwards max 199 bytes phone → device and 204 bytes device → phone |
45
+ | PHY | never changed: no LE Set PHY or PHY Update Complete in the capture, so the link stays on 1M |
46
+ | Preferred connection parameters (GAP `0x2A04`) | interval 20–40 ms, latency 0, supervision timeout 4 s |
47
+ | Connection parameters used | 18.75 ms at connection; 48.75 ms with latency 5 during the file transfers |
48
+
49
+ With MTU 200, one data notification is 3 (ATT header) + 197 bytes, which plus the 4-byte L2CAP
50
+ header is exactly 204 bytes, so each `fda4` data packet travels in a single link-layer packet.
51
+ It's not visible in the log who requested the change to 48.75 ms: the device sent no L2CAP
52
+ connection parameter update request.
53
+
54
+ ## 2. Frame format (`fda1`, `fda2`, `fda3`) — confirmed
55
+
56
+ ```
57
+ 7e | ctl | len (2, BE) | cmd (2, BE) | payload | crc (2, BE) | 7f
58
+ ```
59
+
60
+ | Field | Description |
61
+ |---|---|
62
+ | `7e` / `7f` | start / end delimiters |
63
+ | `ctl` | high nibble = frame type (§3), low nibble = sequence number 0–15 |
64
+ | `len` | length of the **unescaped** frame **without** the `7e`/`7f` delimiters |
65
+ | `cmd` | command id (§4); absent in ack frames |
66
+ | `payload` | usually a protobuf message (§5); may be empty |
67
+ | `crc` | CRC-16/CCITT-FALSE (poly `0x1021`, init `0xFFFF`, no reflection, xorout 0) over `ctl` … end of `payload` |
68
+
69
+ **Escaping:** inside the frame (between the delimiters, including the CRC), the bytes `7d`, `7e` and
70
+ `7f` are sent as `7d 01`, `7d 02` and `7d 03`. `len` and `crc` are computed over the unescaped bytes.
71
+ Because `7f` only ever appears as the end delimiter, frames split across notifications can be
72
+ reassembled by buffering until `7f`.
73
+
74
+ **Ack frames** carry only `ctl`, `len = 0005` and the CRC.
75
+
76
+ Examples:
77
+
78
+ ```
79
+ 7e 00 00 07 00 01 84 bd 7f request, seq 0, cmd 0x0001, no payload
80
+ 7e 20 00 05 1a ff 7f ack of response seq 0
81
+ 7e 05 00 0c 00 29 0a 03 61 7d 01 62 0c dc 7f payload "a}b": the 7d byte is escaped as 7d 01
82
+ ```
83
+
84
+ ## 3. Frame types and channels — confirmed
85
+
86
+ | Type (`ctl & 0xF0`) | Direction | Characteristic | Meaning |
87
+ |---|---|---|---|
88
+ | `0x00` | app → device | `fda1` | request |
89
+ | `0x10` | device → app | `fda1` | response (same seq as the request) |
90
+ | `0x20` | app → device | `fda1` | app acks a response (same seq) |
91
+ | `0x40` | app → device | `fda2` (SET_TIME: `fda1`) | command |
92
+ | `0x60` | device → app | `fda2` | device acks a command (same seq) |
93
+ | `0x80` | device → app | `fda3` | event |
94
+ | `0xA0` | app → device | `fda3` | app acks an event (same seq) |
95
+
96
+ - The app uses **one** sequence counter for request (`0x0_`) and command (`0x4_`) frames. It wraps at
97
+ 16 and starts at 0 on each connection.
98
+ - The device's event counter (`0x8_`) is independent and was **not** reset on reconnection. Acks
99
+ simply echo whatever seq the device sent.
100
+ - The app acks every response and every event immediately.
101
+
102
+ ## 4. Commands
103
+
104
+ | cmd | Frame | Request payload | Response / ack | Status |
105
+ |---|---|---|---|---|
106
+ | `0x0001` | request | – | device info, §5.1 | confirmed |
107
+ | `0x0002` | command | set time, §5.2 | ack | observed |
108
+ | `0x0004` | request | – | `{1:1, 3:1, 4:1, 5:1, 6:1, 9:1, 10:1, 11:1}` (capability flags?) | unknown |
109
+ | `0x0029` | request | fetch file `{1: filename}` | `{1: 1}` when accepted | confirmed |
110
+ | `0x002b` | event (download), command (upload) | file header `{1: filename, 2: sint64 size}` | – | confirmed for download |
111
+ | `0x002c` | command | upload data chunk, §6.2 | ack | observed |
112
+ | `0x002e` | event | end of download, no payload | – | confirmed |
113
+ | `0x002f` | command | `{1: 1}` = download received OK | ack | confirmed |
114
+ | `0x0032` | request | – | empty (status / idle check) | confirmed |
115
+ | `0x0033` | request | – | storage `{1: free, 2: total}` in 512-byte blocks, e.g. `{1: 3384, 2: 55168}` = 25.3M/26.9M used (93%) | confirmed |
116
+ | `0x0036` | request | – | GNSS/AGPS info, §5.3 | observed |
117
+
118
+ ## 5. Payloads
119
+
120
+ Payloads are protobuf wire format with no length prefix. Field numbers are listed below; names are
121
+ guesses.
122
+
123
+ ### 5.1 Device info (`0x0001` response) — confirmed
124
+
125
+ | Field | Type | Example | Guess |
126
+ |---|---|---|---|
127
+ | 1 | varint | 2 | ? |
128
+ | 2 | varint | 3 | ? |
129
+ | 3 | string | `V1.41` | hardware revision |
130
+ | 4 | string | `V1.5.0` | firmware revision |
131
+ | 5 | string | `1234567` | serial number (also in the advertised name) |
132
+ | 6 | string | `V1.0` | ? |
133
+ | 7 | varint | 200 | ATT MTU? (matches the MTU the device requests, §1.1) |
134
+
135
+ ```
136
+ 7e 10 00 2c 00 01 08 02 10 03 1a 05 56 31 2e 34 31 22 06 56 31 2e 35 2e 30
137
+ 2a 07 31 32 33 34 35 36 37 32 04 56 31 2e 30 38 c8 01 51 f9 7f
138
+ ```
139
+
140
+ ### 5.2 Set time (`0x0002`) — observed
141
+
142
+ | Field | Type | Meaning |
143
+ |---|---|---|
144
+ | 1 | fixed32 | Unix time (UTC) |
145
+ | 2 | float | timezone offset in hours |
146
+
147
+ The app sends it once per connection, **on `fda1`** with command type `0x4_`; the device acks it on
148
+ `fda2`. Example for 2026-01-01 00:00:00 UTC, UTC+0:
149
+
150
+ ```
151
+ 7e 44 00 11 00 02 0d 00 b9 55 69 15 00 00 00 00 c5 b4 7f
152
+ ```
153
+
154
+ ### 5.3 GNSS info (`0x0036` response) — observed
155
+
156
+ `{3: "UBX 10", 4: fixed32 Unix time, 5: varint, 6: {1: varint, 2: float latitude, 3: float longitude}}`.
157
+ This looks like the GNSS receiver model plus the last position/time, probably used for AGPS. Not
158
+ needed for file transfer.
159
+
160
+ ## 6. File transfer
161
+
162
+ ### 6.1 Download (device → app) — confirmed
163
+
164
+ ```
165
+ fda1 → 7e 01 00 07 00 32 28 dc 7f STATUS request
166
+ fda1 ← 7e 11 00 07 00 32 2c 86 7f STATUS response
167
+ fda1 → 7e 21 00 05 … ack
168
+ fda1 → 7e 02 00 15 00 29 0a 0c "filelist.txt" 6f ca 7f FETCH_FILE {1: "filelist.txt"}
169
+ fda1 ← 7e 12 00 09 00 29 08 01 8f f5 7f accepted {1: 1}
170
+ fda1 → 7e 22 00 05 … ack
171
+ fda3 ← 7e 80 00 18 00 2b 0a 0c "filelist.txt" 10 88 2d a5 f8 7f
172
+ FILE_HEADER {1: name, 2: 5768 → size 2884}
173
+ fda3 → 7e a0 00 05 21 a5 7f ack
174
+ fda4 ← data packets 0, 1, 2, …
175
+ fda3 ← 7e 81 00 07 00 2e d9 b1 7f FILE_DONE
176
+ fda3 → 7e a1 00 05 … ack
177
+ fda2 → 7e 43 00 09 00 2f 08 01 6f a3 7f FILE_CONFIRM {1: 1}
178
+ fda2 ← 7e 63 00 05 5e 02 7f ack
179
+ ```
180
+
181
+ - **File size:** field 2 of FILE_HEADER is a zigzag-encoded (`sint`) varint, so the raw value is
182
+ twice the size. `5768` → `2884` bytes.
183
+ - **Data packets on `fda4`:** they are not framed or escaped:
184
+
185
+ ```
186
+ index (4, BE, starting at 0) | data | checksum (1)
187
+ ```
188
+
189
+ `checksum = sum(index + data) & 0xFF`. With MTU 200 each packet carries 192 data bytes; the last
190
+ one is shorter. Concatenating the data of all packets gives exactly the header size. No per-packet
191
+ acks or retransmission were observed; the device streams the whole file.
192
+ - The file list is plain text, one `YYYYMMDDhhmmss.fit <size>` entry per line, separated by CRLF.
193
+ FIT files are fetched by these names with the same sequence.
194
+ - FILE_CONFIRM does not appear to delete anything: a fetched FIT file is still listed afterwards.
195
+ What happens without FILE_CONFIRM, or with `{1: 0}`, is unknown.
196
+
197
+ Reference transfers:
198
+ - CoospoRide (Android): `Setting.json` (14.6 KB) streamed in about 2 s, roughly 60 kbit/s of file
199
+ data, at a 48.75 ms connection interval with DLE and 1M PHY (§1.1). The connection interval
200
+ probably limits this more than the packet size does.
201
+ - coospo-cli (Linux/BlueZ): an 82 KB FIT file took about 8 s, including scanning and connecting.
202
+
203
+ ### 6.2 Upload (app → device) — partially observed, not implemented
204
+
205
+ The app uploaded a short `Setting.json` fragment that sets the timezone. For
206
+ `{"time_profile":{"time_zone":0}}` (32 bytes) the frames would be:
207
+
208
+ ```
209
+ fda2 → command 0x002b {1: "Setting.json", 2: 64 (zigzag → 32)} ← ack on fda2
210
+ fda2 → command 0x002c payload: 00 03 + raw JSON bytes (not protobuf) ← ack on fda2
211
+ ```
212
+
213
+ The meaning of the 2-byte prefix `00 03` is unknown, as is how larger files are chunked.
214
+
215
+ ## 7. Service `0xFD00` (`fd0a` → `fd09`) — observed
216
+
217
+ This service uses a separate, simpler framing:
218
+
219
+ ```
220
+ lead (a2 app → device, d2 device → app) | total length (1) | cmd (1) | data | sum8 of all preceding bytes
221
+ ```
222
+
223
+ The app sent two queries:
224
+
225
+ ```
226
+ fd0a → a2 04 80 26 fd09 ← d2 0b 80 00 <6-byte address> <sum>
227
+ fd0a → a2 04 82 28 fd09 ← d2 0a 82 "COOSPO" 31
228
+ ```
229
+
230
+ Its purpose is unclear (possibly bootloader or OTA related). File transfer doesn't need it.
231
+
232
+ ## 8. Other notes
233
+
234
+ - The device's `Setting.json` contains `"display_profile": {"version": "BC600_V1.3", …}`, which
235
+ suggests other current COOSPO models (e.g. the BC600) may share this protocol. Not tested.
236
+ - The device stops advertising while another central (e.g. the phone app) is connected to it.
@@ -6,7 +6,7 @@ CoospoRide app.
6
6
 
7
7
  ```
8
8
  $ coospo info
9
- Searching for CS500 or CS600...
9
+ Searching for Coospo compatible device...
10
10
  Connected to CS500-1234567
11
11
  Model: CS500
12
12
  Serial: 1234567
@@ -31,7 +31,7 @@ If you own a CS600 or another model, please report whether it works in an
31
31
 
32
32
  ## Installation
33
33
 
34
- Requires Python 3.9+ and a Bluetooth LE adapter. It uses [bleak](https://github.com/hbldh/bleak), so
34
+ Requires Python 3.13+ and a Bluetooth LE adapter. It uses [bleak](https://github.com/hbldh/bleak), so
35
35
  it should run on Linux (BlueZ), Windows and macOS; so far it's been tested on Linux.
36
36
 
37
37
  ```
@@ -98,6 +98,31 @@ what the official app does:
98
98
  your location, device addresses and ride history. Describe what you did and share the relevant
99
99
  frames instead.
100
100
 
101
+ ## Automatic sync
102
+
103
+ `coospo sync` downloads the activities the index (`~/.local/state/coospo/index.db`) does not know
104
+ yet. `coospo watch` does the same whenever the device comes into range, at most once per `--interval`
105
+ (default 10 minutes). Both can pass each new activity to a command, e.g. an uploader:
106
+
107
+ ```
108
+ coospo init # once: ignore the rides already on the device
109
+ coospo watch --post-sync ~/bin/strava-upload
110
+ ```
111
+
112
+ `contrib/strava-upload` is such an uploader for Strava (standard library only). Create an API
113
+ application at strava.com/settings/api with the callback domain `localhost`, copy the script to
114
+ `~/bin`, and run `strava-upload auth` once.
115
+
116
+ - The command is run as `CMD FILE`, with `COOSPO_FILE`, `COOSPO_SERIAL` and `COOSPO_ACTIVITY_TIME`
117
+ in the environment. If it exits non-zero it is retried with growing delays (1 min up to 6 h).
118
+ - Downloaded files are deleted once the command succeeds. With `--output DIR` they are kept there.
119
+ Without `--post-sync`, files are always kept (default `~/.local/share/coospo/fit_files`).
120
+ - A file that is still being recorded is not a complete FIT file; it is skipped and fetched again later.
121
+ - Settings can go in `~/.config/coospo/config.toml` (or `$XDG_CONFIG_HOME/coospo/config.toml`, or
122
+ the file given with `-c`): `name`, `address`, `scan_timeout`, `output`, `post_sync`, `interval`.
123
+ See `contrib/config.toml` for a commented sample.
124
+ - `contrib/` has a systemd user unit and an OpenRC init script that run `coospo watch`.
125
+
101
126
  ## Development
102
127
 
103
128
  ```
@@ -0,0 +1,22 @@
1
+ # Sample coospo config. Copy it to ~/.config/coospo/config.toml
2
+ # ($XDG_CONFIG_HOME/coospo/config.toml if that variable is set), or pass another file with -c.
3
+ # Every setting is optional; command-line options override the file.
4
+
5
+ # Connect to the device whose Bluetooth name contains this (default: any CS500 or CS600).
6
+ # name = "CS500-1234567"
7
+
8
+ # Connect to this Bluetooth address instead.
9
+ # address = "AA:BB:CC:DD:EE:FF"
10
+
11
+ # Seconds to search for the device (default: 30).
12
+ # scan_timeout = 30
13
+
14
+ # Keep downloaded activities in this directory. Without it, files are deleted once post_sync
15
+ # succeeds, and kept in ~/.local/share/coospo/fit_files if there is no post_sync.
16
+ # output = "~/rides"
17
+
18
+ # Command run as "CMD FILE" for every new activity, e.g. an uploader. Run `coospo init` first.
19
+ # post_sync = "/home/me/bin/strava-upload"
20
+
21
+ # `coospo watch`: minimum seconds between two syncs of the same device (default: 600).
22
+ # interval = 600
@@ -0,0 +1,24 @@
1
+ #!/sbin/openrc-run
2
+ # Install: cp contrib/openrc/coospo /etc/init.d/ && rc-update add coospo default
3
+ # OpenRC has no user services, so this runs as a normal user. Set the user in
4
+ # /etc/conf.d/coospo (see coospo.confd); that user must be in the bluetooth group and
5
+ # must have run `coospo init` and have ~/.config/coospo/config.toml.
6
+
7
+ description="Sync the COOSPO bike computer when it is in range"
8
+
9
+ : ${coospo_user:=root}
10
+
11
+ command="/usr/bin/coospo"
12
+ command_args="watch"
13
+ command_user="${coospo_user}"
14
+ command_background=true
15
+ pidfile="/run/${RC_SVCNAME}.pid"
16
+ output_log="/var/log/${RC_SVCNAME}.log"
17
+ error_log="/var/log/${RC_SVCNAME}.log"
18
+ supervisor=supervise-daemon
19
+ respawn_delay=30
20
+ respawn_max=0 # never give up: the watcher should keep waiting for the device
21
+
22
+ depend() {
23
+ need bluetooth
24
+ }
@@ -0,0 +1,4 @@
1
+ # Install as /etc/conf.d/coospo
2
+ # The user `coospo watch` runs as. It needs to be in the bluetooth group, and `coospo init` and
3
+ # ~/.config/coospo/config.toml belong to this user's home directory.
4
+ coospo_user="alice"
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ """Upload a FIT file to Strava; meant to be used as `coospo watch --post-sync`.
3
+
4
+ strava-upload auth one-time: authorize this script (opens the browser)
5
+ strava-upload test check the credentials by refreshing the access token
6
+ strava-upload FILE upload FILE; exit 0 on success and when Strava already has the activity
7
+
8
+ Setup: create an API application at https://www.strava.com/settings/api with the authorization
9
+ callback domain "localhost", then run `strava-upload auth` and enter its client ID and secret.
10
+ Tokens are kept in ~/.config/coospo/strava.json (readable by you only; see contrib/strava.json
11
+ for the layout, normally written by `auth`). The activity gets
12
+ Strava's default name; the file name is sent as external_id so Strava can spot duplicates.
13
+ Only the Python standard library is used.
14
+ """
15
+
16
+ import http.server
17
+ import json
18
+ import mimetypes
19
+ import os
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+ import uuid
26
+ import webbrowser
27
+
28
+ API = os.environ.get('STRAVA_API', 'https://www.strava.com') # overridable for testing
29
+ TOKEN_FILE = os.path.join(os.environ.get('XDG_CONFIG_HOME') or os.path.expanduser('~/.config'),
30
+ 'coospo', 'strava.json')
31
+ POLL_SECONDS = 120 # how long to wait for Strava to process an upload
32
+
33
+
34
+ def fail(message, code=1):
35
+ print(f"strava-upload: {message}", file=sys.stderr)
36
+ sys.exit(code)
37
+
38
+
39
+ def request(method, url, data=None, headers=None):
40
+ '''Return (status, parsed JSON or {}); network errors exit 1 so the caller retries later.'''
41
+ req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
42
+ try:
43
+ with urllib.request.urlopen(req, timeout=60) as response:
44
+ status, body = response.status, response.read()
45
+ except urllib.error.HTTPError as e:
46
+ status, body = e.code, e.read()
47
+ except OSError as e:
48
+ fail(f"cannot reach Strava: {e}")
49
+ try:
50
+ return status, json.loads(body)
51
+ except ValueError:
52
+ return status, {}
53
+
54
+
55
+ def load_tokens():
56
+ try:
57
+ with open(TOKEN_FILE) as file:
58
+ return json.load(file)
59
+ except FileNotFoundError:
60
+ fail(f"{TOKEN_FILE} not found; run 'strava-upload auth' first", 2)
61
+
62
+
63
+ def save_tokens(tokens):
64
+ os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True)
65
+ tmp = TOKEN_FILE + '.tmp'
66
+ with open(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), 'w') as file:
67
+ json.dump(tokens, file, indent=2)
68
+ os.replace(tmp, TOKEN_FILE)
69
+
70
+
71
+ def token_request(tokens, **fields):
72
+ '''POST to the OAuth token endpoint and store the credentials it returns.'''
73
+ fields.update(client_id=tokens['client_id'], client_secret=tokens['client_secret'])
74
+ status, reply = request('POST', f'{API}/oauth/token', urllib.parse.urlencode(fields).encode())
75
+ if status != 200 or 'access_token' not in reply:
76
+ fail(f"token request failed ({status}): {reply.get('message', reply)}")
77
+ for key in ('access_token', 'refresh_token', 'expires_at'):
78
+ tokens[key] = reply[key]
79
+ save_tokens(tokens)
80
+
81
+
82
+ def authorize():
83
+ client_id = input('Client ID: ').strip()
84
+ client_secret = input('Client secret: ').strip()
85
+
86
+ class Callback(http.server.BaseHTTPRequestHandler):
87
+ def do_GET(self):
88
+ query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
89
+ if 'code' in query or 'error' in query: # not for favicon requests and the like
90
+ self.server.query = query
91
+ self.send_response(200)
92
+ self.send_header('Content-Type', 'text/plain')
93
+ self.end_headers()
94
+ self.wfile.write(b'strava-upload is authorized; you can close this tab.\n')
95
+
96
+ def log_message(self, *args):
97
+ pass
98
+
99
+ server = http.server.HTTPServer(('127.0.0.1', 0), Callback)
100
+ server.timeout = 5
101
+ server.query = None
102
+ redirect = f'http://localhost:{server.server_port}/'
103
+ url = f'{API}/oauth/authorize?' + urllib.parse.urlencode({
104
+ 'client_id': client_id, 'response_type': 'code', 'redirect_uri': redirect,
105
+ 'approval_prompt': 'auto', 'scope': 'activity:write'})
106
+ print(f"Open this URL if the browser does not start:\n{url}")
107
+ webbrowser.open(url)
108
+ deadline = time.monotonic() + 300
109
+ while server.query is None:
110
+ if time.monotonic() > deadline:
111
+ fail("no answer from the browser after 5 minutes")
112
+ server.handle_request()
113
+ query = server.query
114
+ if 'code' not in query:
115
+ fail(f"authorization refused: {query.get('error', ['no code'])[0]}")
116
+ if 'activity:write' not in query.get('scope', ['activity:write'])[0]:
117
+ fail("the activity:write permission was not granted; run auth again and keep it ticked")
118
+ token_request({'client_id': client_id, 'client_secret': client_secret},
119
+ code=query['code'][0], grant_type='authorization_code')
120
+ print(f"Done. Tokens saved to {TOKEN_FILE}")
121
+
122
+
123
+ def test():
124
+ tokens = load_tokens()
125
+ token_request(tokens, grant_type='refresh_token', refresh_token=tokens['refresh_token'])
126
+ expires = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(tokens['expires_at']))
127
+ print(f"Strava accepted the credentials; the new access token is valid until {expires}.")
128
+
129
+
130
+ def access_token():
131
+ tokens = load_tokens()
132
+ if tokens['expires_at'] < time.time() + 60:
133
+ token_request(tokens, grant_type='refresh_token', refresh_token=tokens['refresh_token'])
134
+ return tokens['access_token']
135
+
136
+
137
+ def multipart(fields, file_field, path):
138
+ boundary = uuid.uuid4().hex
139
+ parts = [f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
140
+ for k, v in fields.items()]
141
+ with open(path, 'rb') as file:
142
+ content = file.read()
143
+ mime = mimetypes.guess_type(path)[0] or 'application/octet-stream'
144
+ parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; '
145
+ f'filename="{os.path.basename(path)}"\r\nContent-Type: {mime}\r\n\r\n'.encode()
146
+ + content + b'\r\n')
147
+ parts.append(f'--{boundary}--\r\n'.encode())
148
+ return b''.join(parts), f'multipart/form-data; boundary={boundary}'
149
+
150
+
151
+ def is_duplicate(error):
152
+ return bool(error) and 'duplicate' in error.lower()
153
+
154
+
155
+ def upload(path):
156
+ token = access_token()
157
+ auth = {'Authorization': f'Bearer {token}'}
158
+ name = os.path.basename(path)
159
+ body, content_type = multipart({'data_type': 'fit', 'external_id': name}, 'file', path)
160
+ status, reply = request('POST', f'{API}/api/v3/uploads', body, dict(auth, **{'Content-Type': content_type}))
161
+ if status == 429 or status >= 500:
162
+ fail(f"Strava is busy or unavailable ({status}); will retry")
163
+ if status >= 400:
164
+ if is_duplicate(reply.get('error')) or any(is_duplicate(e.get('code')) or is_duplicate(e.get('field'))
165
+ for e in reply.get('errors', [])):
166
+ print(f"{name}: Strava already has this activity")
167
+ return
168
+ fail(f"upload of {name} rejected ({status}): {reply.get('message', reply)}")
169
+
170
+ deadline = time.monotonic() + POLL_SECONDS
171
+ while True:
172
+ if is_duplicate(reply.get('error')):
173
+ print(f"{name}: Strava already has this activity")
174
+ return
175
+ if reply.get('error'):
176
+ fail(f"{name}: {reply['error']}")
177
+ if reply.get('activity_id'):
178
+ print(f"{name}: https://www.strava.com/activities/{reply['activity_id']}")
179
+ return
180
+ if time.monotonic() > deadline:
181
+ fail(f"{name}: still processing after {POLL_SECONDS} s; will retry")
182
+ time.sleep(2)
183
+ status, reply = request('GET', f"{API}/api/v3/uploads/{reply['id']}", headers=auth)
184
+ if status != 200:
185
+ fail(f"checking the upload failed ({status})")
186
+
187
+
188
+ def main(argv):
189
+ if argv == ['auth']:
190
+ authorize()
191
+ elif argv == ['test']:
192
+ test()
193
+ elif len(argv) == 1 and not argv[0].startswith('-'):
194
+ upload(argv[0])
195
+ else:
196
+ fail("usage: strava-upload auth | test | FILE", 2)
197
+
198
+
199
+ if __name__ == '__main__':
200
+ main(sys.argv[1:])
@@ -0,0 +1,7 @@
1
+ {
2
+ "client_id": "12345",
3
+ "client_secret": "0123456789abcdef0123456789abcdef01234567",
4
+ "access_token": "fedcba9876543210fedcba9876543210fedcba98",
5
+ "refresh_token": "89abcdef0123456789abcdef0123456789abcdef",
6
+ "expires_at": 0
7
+ }
@@ -0,0 +1,15 @@
1
+ # Install as a user service:
2
+ # cp contrib/systemd/coospo.service ~/.config/systemd/user/
3
+ # systemctl --user enable --now coospo
4
+ # Run `coospo init` once first, and put post_sync etc. into ~/.config/coospo/config.toml.
5
+ [Unit]
6
+ Description=Sync the COOSPO bike computer when it is in range
7
+ After=bluetooth.target
8
+
9
+ [Service]
10
+ ExecStart=%h/.local/bin/coospo watch
11
+ Restart=on-failure
12
+ RestartSec=30
13
+
14
+ [Install]
15
+ WantedBy=default.target
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "coospo-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Command-line tool for COOSPO CS500/CS600 bike computers: device info, battery, memory usage and FIT file download over Bluetooth LE"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
11
11
  license-files = ["LICENSE"]
12
- requires-python = ">=3.9"
12
+ requires-python = ">=3.13"
13
13
  dependencies = ["bleak>=0.22"]
14
14
  keywords = ["coospo", "cs500", "cs600", "bike computer", "cycling", "fit", "bluetooth", "ble"]
15
15
  classifiers = [