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.
- coospo_cli-0.2.0/MANIFEST.in +3 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/PKG-INFO +29 -4
- coospo_cli-0.2.0/PROTOCOL.md +236 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/README.md +27 -2
- coospo_cli-0.2.0/contrib/config.toml +22 -0
- coospo_cli-0.2.0/contrib/openrc/coospo +24 -0
- coospo_cli-0.2.0/contrib/openrc/coospo.confd +4 -0
- coospo_cli-0.2.0/contrib/strava-upload +200 -0
- coospo_cli-0.2.0/contrib/strava.json +7 -0
- coospo_cli-0.2.0/contrib/systemd/coospo.service +15 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/pyproject.toml +2 -2
- coospo_cli-0.2.0/src/coospo/cli.py +194 -0
- coospo_cli-0.2.0/src/coospo/config.py +41 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo/device.py +31 -8
- coospo_cli-0.2.0/src/coospo/fit.py +33 -0
- coospo_cli-0.2.0/src/coospo/index.py +87 -0
- coospo_cli-0.2.0/src/coospo/sync.py +112 -0
- coospo_cli-0.2.0/src/coospo/watch.py +45 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/PKG-INFO +29 -4
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/SOURCES.txt +16 -1
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/tests/test_protocol.py +1 -1
- coospo_cli-0.2.0/tests/test_sync.py +202 -0
- coospo_cli-0.2.0/tools/gatt_dump.py +63 -0
- coospo_cli-0.1.0/src/coospo/cli.py +0 -140
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/LICENSE +0 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/setup.cfg +0 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo/__init__.py +0 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo/__main__.py +0 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo/protocol.py +0 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/dependency_links.txt +0 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/entry_points.txt +0 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/requires.txt +0 -0
- {coospo_cli-0.1.0 → coospo_cli-0.2.0}/src/coospo_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coospo-cli
|
|
3
|
-
Version: 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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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,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,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.
|
|
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.
|
|
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 = [
|