tenways-ble 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,26 @@
1
+ from .client import (
2
+ TenwaysBatteryError,
3
+ TenwaysBike,
4
+ TenwaysError,
5
+ TenwaysTimeoutError,
6
+ TenwaysUnsupportedError,
7
+ scan_tenways,
8
+ )
9
+ from .models import TenwaysAdvertisement, TenwaysInfo
10
+ from .protocol import TenwaysDeviceInfo, TenwaysProtocolError, TenwaysStatus
11
+
12
+ __all__ = [
13
+ "TenwaysAdvertisement",
14
+ "TenwaysBatteryError",
15
+ "TenwaysBike",
16
+ "TenwaysDeviceInfo",
17
+ "TenwaysError",
18
+ "TenwaysInfo",
19
+ "TenwaysProtocolError",
20
+ "TenwaysStatus",
21
+ "TenwaysTimeoutError",
22
+ "TenwaysUnsupportedError",
23
+ "scan_tenways",
24
+ ]
25
+
26
+ __version__ = "0.2.0"
tenways_ble/cli.py ADDED
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ from datetime import datetime
6
+
7
+ from .client import TenwaysBike, scan_tenways
8
+
9
+
10
+ def _parser() -> argparse.ArgumentParser:
11
+ parser = argparse.ArgumentParser(prog="tenways", description="TENWAYS BLE diagnostics")
12
+ sub = parser.add_subparsers(dest="command", required=True)
13
+
14
+ scan = sub.add_parser("scan", help="Scan for nearby TENWAYS BLE devices")
15
+ scan.add_argument("--timeout", type=float, default=5.0)
16
+
17
+ battery = sub.add_parser("battery", help="Read standard BLE battery level")
18
+ battery.add_argument("address")
19
+
20
+ info = sub.add_parser("info", help="Show device name, battery and services")
21
+ info.add_argument("address")
22
+
23
+ status = sub.add_parser("status", help="Read live telemetry (King Meter command 0x0B)")
24
+ status.add_argument("address")
25
+
26
+ device_info = sub.add_parser("device-info", help="Read firmware and serial number")
27
+ device_info.add_argument("address")
28
+
29
+ listen = sub.add_parser("listen", help="Subscribe to raw TENWAYS notifications")
30
+ listen.add_argument("address")
31
+
32
+ return parser
33
+
34
+
35
+ async def _scan(timeout: float) -> int:
36
+ devices = await scan_tenways(timeout)
37
+ if not devices:
38
+ print("No TENWAYS BLE devices found.")
39
+ return 1
40
+ for device in devices:
41
+ rssi = f"{device.rssi} dBm" if device.rssi is not None else "RSSI n/a"
42
+ print(f"{device.address:<20} {device.name or '<unnamed>':<28} {rssi}")
43
+ return 0
44
+
45
+
46
+ async def _battery(address: str) -> int:
47
+ async with TenwaysBike(address) as bike:
48
+ value = await bike.battery_level()
49
+ print(f"Battery: {value} %")
50
+ return 0
51
+
52
+
53
+ async def _info(address: str) -> int:
54
+ async with TenwaysBike(address) as bike:
55
+ info = await bike.info()
56
+ print(f"Address: {info.address}")
57
+ print(f"Name: {info.name or '<unknown>'}")
58
+ print(f"Battery: {info.battery_level if info.battery_level is not None else '<unavailable>'}%")
59
+ print("Services:")
60
+ for uuid in info.services:
61
+ print(f" {uuid}")
62
+ return 0
63
+
64
+
65
+ async def _status(address: str) -> int:
66
+ async with TenwaysBike(address) as bike:
67
+ status = await bike.read_status()
68
+ print(f"Traction battery: {status.battery_percent} %")
69
+ print(f"Odometer: {status.odometer_km:.1f} km")
70
+ print(f"Trip: {status.trip_km:.1f} km")
71
+ print(f"Max speed: {status.max_speed_kmh:.1f} km/h")
72
+ print(f"Average speed: {status.average_speed_kmh:.1f} km/h")
73
+ print(f"Ride time: {status.ride_time_s} s")
74
+ print(f"Speed (unconfirmed): {status.speed_kmh}")
75
+ print(f"Unknown 0x0B/0x11/0x23/0x4F: {status.raw_0b} {status.raw_11} {status.raw_23} {status.raw_4f}")
76
+ return 0
77
+
78
+
79
+ async def _device_info(address: str) -> int:
80
+ async with TenwaysBike(address) as bike:
81
+ info = await bike.read_device_info()
82
+ print(f"Firmware: {info.firmware or '<unknown>'}")
83
+ print(f"Serial: {info.serial or '<unknown>'}")
84
+ return 0
85
+
86
+
87
+ async def _listen(address: str) -> int:
88
+ stop = asyncio.Event()
89
+
90
+ def on_packet(data: bytes) -> None:
91
+ now = datetime.now().astimezone().isoformat(timespec="milliseconds")
92
+ print(f"RX {now} {data.hex(' ')}", flush=True)
93
+
94
+ async with TenwaysBike(address) as bike:
95
+ try:
96
+ battery = await bike.battery_level()
97
+ print(f"Battery: {battery} %")
98
+ except Exception as exc:
99
+ print(f"Battery read failed: {exc}")
100
+
101
+ await bike.start_raw_notifications(on_packet)
102
+ print("Subscribed to proprietary TENWAYS notifications.")
103
+ print("Waiting for packets; press Ctrl+C to stop.")
104
+
105
+ try:
106
+ await stop.wait()
107
+ except asyncio.CancelledError:
108
+ raise
109
+ finally:
110
+ try:
111
+ await bike.stop_raw_notifications()
112
+ except Exception:
113
+ pass
114
+ return 0
115
+
116
+
117
+ async def _amain(args: argparse.Namespace) -> int:
118
+ if args.command == "scan":
119
+ return await _scan(args.timeout)
120
+ if args.command == "battery":
121
+ return await _battery(args.address)
122
+ if args.command == "info":
123
+ return await _info(args.address)
124
+ if args.command == "status":
125
+ return await _status(args.address)
126
+ if args.command == "device-info":
127
+ return await _device_info(args.address)
128
+ if args.command == "listen":
129
+ return await _listen(args.address)
130
+ raise RuntimeError("Unhandled command")
131
+
132
+
133
+ def main() -> None:
134
+ parser = _parser()
135
+ args = parser.parse_args()
136
+ try:
137
+ code = asyncio.run(_amain(args))
138
+ except KeyboardInterrupt:
139
+ code = 130
140
+ raise SystemExit(code)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
tenways_ble/client.py ADDED
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ import logging
6
+ from typing import Any
7
+
8
+ from bleak import BleakClient, BleakScanner
9
+ from bleak.backends.device import BLEDevice
10
+ from bleak.backends.scanner import AdvertisementData
11
+
12
+ from .const import (
13
+ BATTERY_LEVEL_UUID,
14
+ TENWAYS_NAME_PREFIXES,
15
+ TENWAYS_NOTIFY_UUID,
16
+ TENWAYS_SERVICE_UUID,
17
+ TENWAYS_WRITE_UUID,
18
+ )
19
+ from .models import TenwaysAdvertisement, TenwaysInfo
20
+ from .protocol import (
21
+ CMD_INFO,
22
+ CMD_STATUS,
23
+ INFO_REQUEST,
24
+ STATUS_REQUEST,
25
+ TYPE_READ,
26
+ FrameAssembler,
27
+ TenwaysDeviceInfo,
28
+ TenwaysProtocolError,
29
+ TenwaysStatus,
30
+ decode_device_info,
31
+ decode_status,
32
+ parse_frame,
33
+ )
34
+
35
+ _LOGGER = logging.getLogger(__name__)
36
+
37
+ NotificationCallback = Callable[[bytes], None | Awaitable[None]]
38
+
39
+
40
+ class TenwaysError(RuntimeError):
41
+ """Base exception for tenways-ble."""
42
+
43
+
44
+ class TenwaysBatteryError(TenwaysError):
45
+ """Raised when the standard battery value cannot be decoded."""
46
+
47
+
48
+ class TenwaysTimeoutError(TenwaysError, TimeoutError):
49
+ """Raised when the bike does not answer a request in time."""
50
+
51
+
52
+ class TenwaysUnsupportedError(TenwaysError):
53
+ """Raised when the device lacks the King Meter service."""
54
+
55
+
56
+ def _looks_like_tenways(device: BLEDevice, adv: AdvertisementData | None) -> bool:
57
+ names = [device.name]
58
+ if adv is not None:
59
+ names.append(adv.local_name)
60
+ if TENWAYS_SERVICE_UUID in (adv.service_uuids or []):
61
+ return True
62
+ return any(
63
+ name and name.lower().startswith(TENWAYS_NAME_PREFIXES) for name in names
64
+ )
65
+
66
+
67
+ async def scan_tenways(timeout: float = 5.0) -> list[TenwaysAdvertisement]:
68
+ """Scan for nearby devices whose advertisement looks like TENWAYS."""
69
+
70
+ found = await BleakScanner.discover(timeout=timeout, return_adv=True)
71
+ result: list[TenwaysAdvertisement] = []
72
+
73
+ for _key, value in found.items():
74
+ device, adv = value
75
+ if not _looks_like_tenways(device, adv):
76
+ continue
77
+ result.append(
78
+ TenwaysAdvertisement(
79
+ address=device.address,
80
+ name=adv.local_name or device.name,
81
+ rssi=adv.rssi,
82
+ )
83
+ )
84
+
85
+ return sorted(result, key=lambda item: item.rssi if item.rssi is not None else -999, reverse=True)
86
+
87
+
88
+ class TenwaysBike:
89
+ """BLE client for a TENWAYS bicycle.
90
+
91
+ Pass an address or ``BLEDevice`` to let this class manage the connection,
92
+ or an already connected ``BleakClient`` (e.g. from bleak-retry-connector)
93
+ to only speak the protocol over it; the caller then owns the connection.
94
+
95
+ Only read requests are sent. Write commands are not implemented.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ address_or_device: str | BLEDevice | BleakClient,
101
+ *,
102
+ timeout: float = 15.0,
103
+ response_timeout: float = 5.0,
104
+ ) -> None:
105
+ self.timeout = timeout
106
+ self.response_timeout = response_timeout
107
+ self._owns_client = not isinstance(address_or_device, BleakClient)
108
+ self.address_or_device = address_or_device
109
+ self._client: BleakClient | None = None if self._owns_client else address_or_device
110
+ self._lock = asyncio.Lock()
111
+ self._assembler = FrameAssembler()
112
+ self._pending: tuple[int, asyncio.Future[bytes]] | None = None
113
+ self._protocol_notify = False
114
+ self._raw_callback: NotificationCallback | None = None
115
+
116
+ @property
117
+ def client(self) -> BleakClient:
118
+ if self._client is None or not self._client.is_connected:
119
+ raise TenwaysError("Bike is not connected")
120
+ return self._client
121
+
122
+ async def connect(self) -> None:
123
+ if not self._owns_client:
124
+ return
125
+ if self._client is not None and self._client.is_connected:
126
+ return
127
+ self._client = BleakClient(
128
+ self.address_or_device,
129
+ timeout=self.timeout,
130
+ disconnected_callback=self._on_disconnect,
131
+ )
132
+ await self._client.connect()
133
+
134
+ async def disconnect(self) -> None:
135
+ self._protocol_notify = False
136
+ self._raw_callback = None
137
+ if not self._owns_client or self._client is None:
138
+ return
139
+ try:
140
+ if self._client.is_connected:
141
+ await self._client.disconnect()
142
+ finally:
143
+ self._client = None
144
+
145
+ async def __aenter__(self) -> "TenwaysBike":
146
+ await self.connect()
147
+ return self
148
+
149
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
150
+ await self.disconnect()
151
+
152
+ def _on_disconnect(self, _client: BleakClient) -> None:
153
+ self._protocol_notify = False
154
+ if self._pending is not None and not self._pending[1].done():
155
+ self._pending[1].set_exception(TenwaysError("Bike disconnected"))
156
+
157
+ async def battery_level(self) -> int:
158
+ """Read the Bluetooth standard Battery Level characteristic.
159
+
160
+ This is not the traction battery; see :meth:`read_status`.
161
+ """
162
+
163
+ raw = bytes(await self.client.read_gatt_char(BATTERY_LEVEL_UUID))
164
+ if len(raw) != 1:
165
+ raise TenwaysBatteryError(f"Unexpected battery payload: {raw.hex(' ')}")
166
+ value = raw[0]
167
+ if not 0 <= value <= 100:
168
+ raise TenwaysBatteryError(f"Invalid battery value: {value}")
169
+ return value
170
+
171
+ async def info(self) -> TenwaysInfo:
172
+ name: str | None = None
173
+ try:
174
+ name = self.client.name
175
+ except Exception:
176
+ pass
177
+
178
+ try:
179
+ battery = await self.battery_level()
180
+ except Exception:
181
+ battery = None
182
+
183
+ services = tuple(str(service.uuid).lower() for service in self.client.services)
184
+ address = getattr(self.client, "address", str(self.address_or_device))
185
+ return TenwaysInfo(
186
+ address=address,
187
+ name=name,
188
+ battery_level=battery,
189
+ services=services,
190
+ )
191
+
192
+ def supports_protocol(self) -> bool:
193
+ services = self.client.services
194
+ return (
195
+ services.get_characteristic(TENWAYS_WRITE_UUID) is not None
196
+ and services.get_characteristic(TENWAYS_NOTIFY_UUID) is not None
197
+ )
198
+
199
+ async def read_status(self) -> TenwaysStatus:
200
+ """Request and decode live telemetry (command 0x0B)."""
201
+
202
+ return decode_status(await self._request(STATUS_REQUEST, CMD_STATUS))
203
+
204
+ async def read_device_info(self) -> TenwaysDeviceInfo:
205
+ """Request firmware and serial number (command 0x04)."""
206
+
207
+ return decode_device_info(await self._request(INFO_REQUEST, CMD_INFO))
208
+
209
+ async def _request(self, request: bytes, cmd: int) -> bytes:
210
+ async with self._lock:
211
+ client = self.client
212
+ if not self.supports_protocol():
213
+ raise TenwaysUnsupportedError("King Meter service not found")
214
+ if not self._protocol_notify:
215
+ self._assembler.reset()
216
+ await client.start_notify(TENWAYS_NOTIFY_UUID, self._on_notify)
217
+ self._protocol_notify = True
218
+ future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future()
219
+ self._pending = (cmd, future)
220
+ try:
221
+ await client.write_gatt_char(TENWAYS_WRITE_UUID, request, response=False)
222
+ async with asyncio.timeout(self.response_timeout):
223
+ return await future
224
+ except TimeoutError as exc:
225
+ raise TenwaysTimeoutError(f"No reply to command {cmd:#04x}") from exc
226
+ finally:
227
+ self._pending = None
228
+
229
+ def _on_notify(self, _sender: Any, data: bytearray) -> None:
230
+ payload = bytes(data)
231
+ if self._raw_callback is not None:
232
+ result = self._raw_callback(payload)
233
+ if asyncio.iscoroutine(result):
234
+ asyncio.create_task(result)
235
+ for raw in self._assembler.feed(payload):
236
+ try:
237
+ frame = parse_frame(raw)
238
+ except TenwaysProtocolError as exc:
239
+ _LOGGER.debug("Dropping invalid frame %s: %s", raw.hex(" "), exc)
240
+ continue
241
+ pending = self._pending
242
+ if (pending is not None and not pending[1].done()
243
+ and frame.type == TYPE_READ and frame.cmd == pending[0]):
244
+ pending[1].set_result(raw)
245
+ else:
246
+ _LOGGER.debug("Unsolicited frame %s", raw.hex(" "))
247
+
248
+ async def start_raw_notifications(self, callback: NotificationCallback) -> None:
249
+ """Subscribe to the proprietary TENWAYS notification characteristic."""
250
+
251
+ self._raw_callback = callback
252
+ if not self._protocol_notify:
253
+ self._assembler.reset()
254
+ await self.client.start_notify(TENWAYS_NOTIFY_UUID, self._on_notify)
255
+ self._protocol_notify = True
256
+
257
+ async def stop_raw_notifications(self) -> None:
258
+ self._raw_callback = None
259
+ self._protocol_notify = False
260
+ await self.client.stop_notify(TENWAYS_NOTIFY_UUID)
tenways_ble/const.py ADDED
@@ -0,0 +1,13 @@
1
+ """UUID constants observed on TENWAYS BLE devices."""
2
+
3
+ BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb"
4
+ BATTERY_LEVEL_UUID = "00002a19-0000-1000-8000-00805f9b34fb"
5
+
6
+ # "KING METER" in ASCII; the proprietary King Meter transport.
7
+ TENWAYS_SERVICE_UUID = "43480001-f001-4b49-4e47-204d45544552"
8
+ TENWAYS_WRITE_UUID = "43480002-f001-4b49-4e47-204d45544552"
9
+ TENWAYS_NOTIFY_UUID = "43480003-f001-4b49-4e47-204d45544552"
10
+ TENWAYS_READ_UUID = "43480004-f001-4b49-4e47-204d45544552"
11
+
12
+ TENWAYS_NAME_PREFIX = "tenways-ble-"
13
+ TENWAYS_NAME_PREFIXES = (TENWAYS_NAME_PREFIX, "longtail duo")
tenways_ble/models.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class TenwaysAdvertisement:
8
+ address: str
9
+ name: str | None
10
+ rssi: int | None
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class TenwaysInfo:
15
+ address: str
16
+ name: str | None
17
+ battery_level: int | None
18
+ services: tuple[str, ...]
@@ -0,0 +1,240 @@
1
+ """King Meter frame protocol used by the TENWAYS Longtail Duo.
2
+
3
+ Sans-I/O: this module only builds and parses bytes. Everything here was
4
+ derived from an Android HCI capture of the official TENWAYS app.
5
+
6
+ Frame layout (both directions)::
7
+
8
+ [type][0x00][len][cmd][payload ...][crc_hi][crc_lo][0x45]
9
+
10
+ ``type`` is 0x62 (read) or 0x63 (write), ``len`` counts cmd + payload, and
11
+ ``crc`` is CRC-16/MODBUS over type..payload, transmitted high byte first.
12
+ A read request lists field keys; the reply carries TLV entries
13
+ ``[key][size][value]`` with big-endian values.
14
+
15
+ Only read requests are built. Write commands (0x63) are parsed but never
16
+ generated.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Mapping
21
+ from dataclasses import dataclass, field
22
+
23
+ TYPE_READ = 0x62
24
+ TYPE_WRITE = 0x63
25
+ FRAME_TYPES = (TYPE_READ, TYPE_WRITE)
26
+ HEADER_BYTE = 0x00
27
+ END_BYTE = 0x45
28
+ OVERHEAD = 6 # type, 0x00, len, crc_hi, crc_lo, end
29
+ MAX_FRAME = 0xFF + OVERHEAD
30
+
31
+ CMD_INFO = 0x04
32
+ CMD_STATUS = 0x0B
33
+
34
+ INFO_KEYS = (0x04, 0x05, 0x4F, 0x70)
35
+ STATUS_KEYS = (0x06, 0x07, 0x08, 0x09, 0x11, 0x0A, 0x0B, 0x0C, 0x0F, 0x23, 0x4F)
36
+
37
+ # Fields confirmed against the TENWAYS app; a reply without them is rejected.
38
+ STATUS_REQUIRED = {0x07: 2, 0x08: 2, 0x09: 4, 0x0A: 4, 0x0C: 4, 0x0F: 1}
39
+ # Plausible or unknown fields; absent or malformed values become None.
40
+ STATUS_OPTIONAL = {0x06: 2, 0x0B: 2, 0x11: 1, 0x23: 2, 0x4F: 1}
41
+
42
+
43
+ class TenwaysProtocolError(ValueError):
44
+ """Raised for frames that fail validation."""
45
+
46
+
47
+ def crc16_modbus(data: bytes) -> int:
48
+ crc = 0xFFFF
49
+ for value in data:
50
+ crc ^= value
51
+ for _ in range(8):
52
+ crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
53
+ return crc
54
+
55
+
56
+ def build_frame(frame_type: int, cmd: int, payload: bytes = b"") -> bytes:
57
+ if frame_type not in FRAME_TYPES:
58
+ raise ValueError(f"invalid frame type {frame_type:#04x}")
59
+ if len(payload) + 1 > 0xFF:
60
+ raise ValueError("payload too long")
61
+ body = bytes((frame_type, HEADER_BYTE, len(payload) + 1, cmd)) + payload
62
+ return body + crc16_modbus(body).to_bytes(2, "big") + bytes((END_BYTE,))
63
+
64
+
65
+ def build_read_request(cmd: int, keys: tuple[int, ...]) -> bytes:
66
+ return build_frame(TYPE_READ, cmd, bytes(keys))
67
+
68
+
69
+ STATUS_REQUEST = build_read_request(CMD_STATUS, STATUS_KEYS)
70
+ INFO_REQUEST = build_read_request(CMD_INFO, INFO_KEYS)
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class Frame:
75
+ type: int
76
+ cmd: int
77
+ payload: bytes
78
+
79
+
80
+ def parse_frame(raw: bytes) -> Frame:
81
+ """Validate one complete frame: header, length, CRC and end byte."""
82
+ if len(raw) < OVERHEAD + 1:
83
+ raise TenwaysProtocolError("frame too short")
84
+ if raw[0] not in FRAME_TYPES:
85
+ raise TenwaysProtocolError(f"invalid frame type {raw[0]:#04x}")
86
+ if raw[1] != HEADER_BYTE:
87
+ raise TenwaysProtocolError(f"invalid header byte {raw[1]:#04x}")
88
+ if raw[2] == 0 or len(raw) != raw[2] + OVERHEAD:
89
+ raise TenwaysProtocolError("frame length mismatch")
90
+ if raw[-1] != END_BYTE:
91
+ raise TenwaysProtocolError("missing end byte")
92
+ if crc16_modbus(raw[:-3]) != int.from_bytes(raw[-3:-1], "big"):
93
+ raise TenwaysProtocolError("CRC mismatch")
94
+ return Frame(raw[0], raw[3], bytes(raw[4:-3]))
95
+
96
+
97
+ def parse_tlv(payload: bytes) -> dict[int, bytes]:
98
+ fields: dict[int, bytes] = {}
99
+ pos = 0
100
+ while pos < len(payload):
101
+ if pos + 2 > len(payload):
102
+ raise TenwaysProtocolError("truncated TLV header")
103
+ key, size = payload[pos], payload[pos + 1]
104
+ value = payload[pos + 2:pos + 2 + size]
105
+ if len(value) != size:
106
+ raise TenwaysProtocolError(f"truncated TLV field {key:#04x}")
107
+ if key in fields:
108
+ raise TenwaysProtocolError(f"duplicate TLV field {key:#04x}")
109
+ fields[key] = value
110
+ pos += 2 + size
111
+ return fields
112
+
113
+
114
+ class FrameAssembler:
115
+ """Split a notification byte stream into candidate frames.
116
+
117
+ Handles frames spread over several notifications, several frames in one
118
+ notification and garbage between frames. Returned frames are delimited
119
+ by their length byte only; validate them with :func:`parse_frame`.
120
+ """
121
+
122
+ def __init__(self, max_buffer: int = 4 * MAX_FRAME) -> None:
123
+ self._buf = bytearray()
124
+ self._max_buffer = max_buffer
125
+
126
+ def reset(self) -> None:
127
+ self._buf.clear()
128
+
129
+ def feed(self, chunk: bytes) -> list[bytes]:
130
+ self._buf += chunk
131
+ frames: list[bytes] = []
132
+ while True:
133
+ self._resync()
134
+ if len(self._buf) < 3:
135
+ break
136
+ total = self._buf[2] + OVERHEAD
137
+ if len(self._buf) < total:
138
+ break
139
+ candidate = bytes(self._buf[:total])
140
+ if candidate[-1] != END_BYTE:
141
+ # Wrong length byte: drop this start byte and search again.
142
+ del self._buf[0]
143
+ continue
144
+ frames.append(candidate)
145
+ del self._buf[:total]
146
+ if len(self._buf) > self._max_buffer:
147
+ self._buf.clear()
148
+ return frames
149
+
150
+ def _resync(self) -> None:
151
+ start = 0
152
+ while start < len(self._buf):
153
+ if self._buf[start] in FRAME_TYPES and (
154
+ start + 1 >= len(self._buf) or self._buf[start + 1] == HEADER_BYTE
155
+ ):
156
+ break
157
+ start += 1
158
+ if start:
159
+ del self._buf[:start]
160
+
161
+
162
+ @dataclass(frozen=True, slots=True)
163
+ class TenwaysStatus:
164
+ """Telemetry from a status reply (command 0x0B)."""
165
+
166
+ battery_percent: int
167
+ max_speed_kmh: float
168
+ average_speed_kmh: float
169
+ odometer_km: float
170
+ ride_time_s: int
171
+ trip_km: float
172
+ # Plausible but not yet confirmed while riding.
173
+ speed_kmh: float | None = None
174
+ # Meaning unknown; exposed raw for further analysis.
175
+ raw_0b: int | None = None
176
+ raw_11: int | None = None
177
+ raw_23: int | None = None
178
+ raw_4f: int | None = None
179
+ fields: Mapping[int, bytes] = field(default_factory=dict, repr=False)
180
+
181
+
182
+ @dataclass(frozen=True, slots=True)
183
+ class TenwaysDeviceInfo:
184
+ """Device information reply (command 0x04)."""
185
+
186
+ firmware: str | None
187
+ serial: str | None
188
+ fields: Mapping[int, bytes] = field(default_factory=dict, repr=False)
189
+
190
+
191
+ def _reply_fields(raw: bytes, cmd: int) -> dict[int, bytes]:
192
+ frame = parse_frame(raw)
193
+ if frame.type != TYPE_READ:
194
+ raise TenwaysProtocolError(f"unexpected frame type {frame.type:#04x}")
195
+ if frame.cmd != cmd:
196
+ raise TenwaysProtocolError(f"unexpected command {frame.cmd:#04x}")
197
+ return parse_tlv(frame.payload)
198
+
199
+
200
+ def _number(fields: Mapping[int, bytes], key: int, size: int) -> int | None:
201
+ value = fields.get(key)
202
+ if value is None or len(value) != size:
203
+ return None
204
+ return int.from_bytes(value, "big")
205
+
206
+
207
+ def decode_status(raw: bytes) -> TenwaysStatus:
208
+ fields = _reply_fields(raw, CMD_STATUS)
209
+ for key, size in STATUS_REQUIRED.items():
210
+ if len(fields.get(key, b"")) != size:
211
+ raise TenwaysProtocolError(f"missing or malformed status field {key:#04x}")
212
+ req = {key: int.from_bytes(fields[key], "big") for key in STATUS_REQUIRED}
213
+ if req[0x0F] > 100:
214
+ raise TenwaysProtocolError(f"battery out of range: {req[0x0F]}")
215
+ opt = {key: _number(fields, key, size) for key, size in STATUS_OPTIONAL.items()}
216
+ return TenwaysStatus(
217
+ battery_percent=req[0x0F],
218
+ max_speed_kmh=req[0x07] / 10,
219
+ average_speed_kmh=req[0x08] / 10,
220
+ odometer_km=req[0x09] / 10,
221
+ ride_time_s=req[0x0A],
222
+ trip_km=req[0x0C] / 10,
223
+ speed_kmh=opt[0x06] / 10 if opt[0x06] is not None else None,
224
+ raw_0b=opt[0x0B],
225
+ raw_11=opt[0x11],
226
+ raw_23=opt[0x23],
227
+ raw_4f=opt[0x4F],
228
+ fields=fields,
229
+ )
230
+
231
+
232
+ def decode_device_info(raw: bytes) -> TenwaysDeviceInfo:
233
+ fields = _reply_fields(raw, CMD_INFO)
234
+ version = fields.get(0x04)
235
+ serial = fields.get(0x70, b"").rstrip(b"\0").decode("ascii", "replace")
236
+ return TenwaysDeviceInfo(
237
+ firmware=".".join(str(b) for b in version) if version and len(version) == 3 else None,
238
+ serial=serial or None,
239
+ fields=fields,
240
+ )
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.5
2
+ Name: tenways-ble
3
+ Version: 0.2.0
4
+ Summary: Read-only BLE client, King Meter protocol and diagnostic CLI for TENWAYS e-bikes
5
+ Project-URL: Homepage, https://github.com/Styx85/tenways-ble
6
+ Project-URL: Issues, https://github.com/Styx85/tenways-ble/issues
7
+ Author: Christoph Berliner
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ble,bluetooth,ebike,home-assistant,king-meter,tenways
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: bleak>=0.22
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # tenways-ble
19
+
20
+ Small experimental Python library and CLI for local BLE access to TENWAYS e-bikes.
21
+
22
+ Read-only by design:
23
+
24
+ - discovers nearby TENWAYS BLE devices;
25
+ - decodes the proprietary **King Meter** frame protocol (CRC-16/MODBUS, TLV fields) used by the TENWAYS Longtail Duo;
26
+ - reads live telemetry: traction battery, odometer, trip, max/average speed, ride time;
27
+ - reads firmware and serial number;
28
+ - reads the standard Bluetooth Battery Level characteristic (`0x2A19`), which is **not** the traction battery;
29
+ - does **not** send any write, lock, light or configuration commands.
30
+
31
+ The protocol was reconstructed from an Android HCI capture of the official TENWAYS app; all test vectors are the original captured frames. Live operation against the bike is still being validated.
32
+
33
+ ## Library usage
34
+
35
+ ```python
36
+ import asyncio
37
+ from tenways_ble import TenwaysBike
38
+
39
+ async def main():
40
+ async with TenwaysBike("E8:88:6C:31:B3:0D") as bike:
41
+ status = await bike.read_status()
42
+ print(status.battery_percent, status.odometer_km)
43
+
44
+ asyncio.run(main())
45
+ ```
46
+
47
+ `TenwaysBike` also accepts an already connected `BleakClient` (for example from `bleak-retry-connector` in Home Assistant). It then only speaks the protocol and leaves the connection to the caller.
48
+
49
+ The sans-I/O protocol module `tenways_ble.protocol` builds and parses frames without any Bluetooth dependency.
50
+
51
+ ## Devices
52
+
53
+ | Device | Advertised name | Notes |
54
+ |---|---|---|
55
+ | Longtail Duo drive | `Longtail Duo` | King Meter service, telemetry source. Standard battery reads ~1 %, not the traction battery. |
56
+ | Separate BLE device | `Tenways-ble-XXXXXX` | HID service, standard battery 100 %, also exposes the King Meter service. Function unconfirmed (remote/control unit?). |
57
+
58
+ ## Known GATT layout
59
+
60
+ Standard battery service:
61
+
62
+ - Service: `0000180f-0000-1000-8000-00805f9b34fb`
63
+ - Battery Level: `00002a19-0000-1000-8000-00805f9b34fb` (`READ`, `NOTIFY`)
64
+
65
+ King Meter service (`43480001-…` contains `KING METER` in ASCII):
66
+
67
+ - Service: `43480001-f001-4b49-4e47-204d45544552`
68
+ - Write: `43480002-f001-4b49-4e47-204d45544552` (`WRITE WITHOUT RESPONSE`)
69
+ - Notify: `43480003-f001-4b49-4e47-204d45544552` (`NOTIFY`)
70
+ - Read: `43480004-f001-4b49-4e47-204d45544552` (`READ`, no useful content observed)
71
+
72
+ ## King Meter protocol
73
+
74
+ ```
75
+ [TYPE] [00] [LEN] [CMD] [PAYLOAD…] [CRC_HI] [CRC_LO] [45]
76
+ ```
77
+
78
+ | Element | Meaning |
79
+ |---|---|
80
+ | TYPE | `0x62` read, `0x63` write |
81
+ | `00` | constant header byte |
82
+ | LEN | length of CMD + PAYLOAD; frame length is LEN + 6 |
83
+ | CRC | CRC-16/MODBUS (`0xA001` reflected, init `0xFFFF`) over TYPE…PAYLOAD, **high byte first** |
84
+ | `45` | end marker |
85
+
86
+ A read request lists field keys; the reply returns `[key][size][value]` entries, big endian. Replies (54 bytes for status) exceed the default 20-byte notification payload and are reassembled.
87
+
88
+ Status request (`CMD 0x0B`), as sent by the app: `62 00 0c 0b 06 07 08 09 11 0a 0b 0c 0f 23 4f 84 91 45`
89
+
90
+ | Key | Size | Meaning | Status |
91
+ |---|---|---|---|
92
+ | `0x06` | 2 | current speed, 0.1 km/h | plausible, 0 while parked |
93
+ | `0x07` | 2 | max speed, 0.1 km/h | matches app |
94
+ | `0x08` | 2 | average speed, 0.1 km/h | matches app |
95
+ | `0x09` | 4 | odometer, 0.1 km | matches app |
96
+ | `0x0A` | 4 | ride time, s | matches app |
97
+ | `0x0B` | 2 | unknown (655 at 68 %; range in 0.1 km?) | open |
98
+ | `0x0C` | 4 | trip, 0.1 km | matches app |
99
+ | `0x0F` | 1 | traction battery, % | plausible |
100
+ | `0x11` | 1 | unknown | open |
101
+ | `0x23` | 2 | unknown | open |
102
+ | `0x4F` | 1 | unknown | open |
103
+
104
+ Device info request (`CMD 0x04`, keys `04 05 4f 70`): key `0x04` holds three version bytes (`2.20.16` observed; the app shows `1.4.0` elsewhere, so this may be another component), key `0x70` the ASCII serial number.
105
+
106
+ The app also writes a clock sync (`TYPE 0x63`, `CMD 0x01`, key `0x3C`). This library deliberately does not implement it.
107
+
108
+ ## Install for development
109
+
110
+ ```bash
111
+ python3 -m venv .venv
112
+ source .venv/bin/activate
113
+ python -m pip install -U pip
114
+ pip install -e '.[dev]'
115
+ ```
116
+
117
+ On Linux, BlueZ and a working Bluetooth adapter are required.
118
+
119
+ ## CLI
120
+
121
+ Scan for likely TENWAYS devices:
122
+
123
+ ```bash
124
+ tenways scan
125
+ ```
126
+
127
+ Read live telemetry (traction battery, odometer, …):
128
+
129
+ ```bash
130
+ tenways status E8:88:6C:31:B3:0D
131
+ ```
132
+
133
+ Read firmware and serial number:
134
+
135
+ ```bash
136
+ tenways device-info E8:88:6C:31:B3:0D
137
+ ```
138
+
139
+ Read the standard BLE battery percentage (not the traction battery):
140
+
141
+ ```bash
142
+ tenways battery E8:88:6C:31:B3:0D
143
+ ```
144
+
145
+ Show basic GATT information and battery level:
146
+
147
+ ```bash
148
+ tenways info E8:88:6C:31:B3:0D
149
+ ```
150
+
151
+ Subscribe to the proprietary notification characteristic:
152
+
153
+ ```bash
154
+ tenways listen E8:88:6C:31:B3:0D
155
+ ```
156
+
157
+ Stop with `Ctrl+C`.
158
+
159
+ ## Optional HCI trace on Linux
160
+
161
+ For traffic generated by this library/CLI on the same Linux Bluetooth controller, BlueZ `btmon` is useful:
162
+
163
+ ```bash
164
+ sudo btmon -i hci0 -w tenways.btsnoop
165
+ ```
166
+
167
+ Then run the CLI in another shell. The resulting `tenways.btsnoop` file can be inspected with Wireshark.
168
+
169
+ This does **not** passively capture a BLE connection between an Android phone and the bicycle. For the original TENWAYS app, use Android HCI snoop logging or a dedicated BLE sniffer.
170
+
171
+ ## Safety
172
+
173
+ `tenways-ble` 0.1.0 never writes arbitrary data to the proprietary TENWAYS write characteristic. Reverse engineering should start from observed traffic rather than guessed commands.
174
+
175
+ ## License
176
+
177
+ MIT
178
+
179
+ Never publish a raw phone HCI log; it contains traffic and identifiers of other devices.
180
+
181
+ ## Releasing
182
+
183
+ Releases are published to PyPI by GitHub Actions via trusted publishing when a `v*` tag is pushed. The tag must match `__version__`.
184
+
185
+ ## Acknowledgements / Credits
186
+
187
+ Based on and inspired by [stein155/cgo600-bridge](https://github.com/stein155/cgo600-bridge), which provided valuable groundwork for understanding the proprietary TENWAYS telemetry protocol and its integration with Home Assistant.
188
+
189
+ Many thanks to the original author and contributors.
190
+
191
+ The Longtail Duo implementation was developed using independently captured Bluetooth HCI traffic from the official TENWAYS app. It uses the King Meter BLE transport rather than the Nordic UART Service used by the CGO600 bridge.
@@ -0,0 +1,11 @@
1
+ tenways_ble/__init__.py,sha256=txOSNn1uzsKDTI_Gc4FDCDT7b2ETFhdR60W1gUfvU_s,590
2
+ tenways_ble/cli.py,sha256=VoS-ODT8ires2xmFeDvz-jK0gyEpPh03ohUH_-w-Vo8,4642
3
+ tenways_ble/client.py,sha256=U9jHeGFO6SqhStqG_mTWwspxlte2W6mFya6KTyckAmA,9044
4
+ tenways_ble/const.py,sha256=8p2tobKei5b2kw1ZumxH4NZn5xniua_zZLf2_k9zyCw,583
5
+ tenways_ble/models.py,sha256=zmgVn2weCKNFNNHAFy3BrEJUtBMeDnmXXCdf3c9Ig0w,350
6
+ tenways_ble/protocol.py,sha256=anAK4YcjJx0YKFLe7SF_oLUvcsjVAsbec-7_G-iYyjc,8123
7
+ tenways_ble-0.2.0.dist-info/METADATA,sha256=1QxcC6AMMdbV1OZOaTkk-skUI6GPRatllacax71BhqM,6717
8
+ tenways_ble-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ tenways_ble-0.2.0.dist-info/entry_points.txt,sha256=FsiLrfFD-vxS37T52WvbJZdB747QYQxfXx0FHqklx8M,49
10
+ tenways_ble-0.2.0.dist-info/licenses/LICENSE,sha256=tJTTqFaZJxTrpyBowRTJa2CwuouUqknt7h6HE3NCyCk,1075
11
+ tenways_ble-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tenways = tenways_ble.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christoph Berliner
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.