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.
- tenways_ble/__init__.py +26 -0
- tenways_ble/cli.py +144 -0
- tenways_ble/client.py +260 -0
- tenways_ble/const.py +13 -0
- tenways_ble/models.py +18 -0
- tenways_ble/protocol.py +240 -0
- tenways_ble-0.2.0.dist-info/METADATA +191 -0
- tenways_ble-0.2.0.dist-info/RECORD +11 -0
- tenways_ble-0.2.0.dist-info/WHEEL +4 -0
- tenways_ble-0.2.0.dist-info/entry_points.txt +2 -0
- tenways_ble-0.2.0.dist-info/licenses/LICENSE +21 -0
tenways_ble/__init__.py
ADDED
|
@@ -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, ...]
|
tenways_ble/protocol.py
ADDED
|
@@ -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,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.
|