tescmd 0.1.2__py3-none-any.whl → 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.
Files changed (47) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +8 -1
  3. tescmd/api/errors.py +8 -0
  4. tescmd/api/vehicle.py +19 -1
  5. tescmd/cache/response_cache.py +3 -2
  6. tescmd/cli/auth.py +30 -2
  7. tescmd/cli/key.py +149 -14
  8. tescmd/cli/main.py +44 -0
  9. tescmd/cli/setup.py +230 -22
  10. tescmd/cli/vehicle.py +464 -1
  11. tescmd/crypto/__init__.py +3 -1
  12. tescmd/crypto/ecdh.py +9 -0
  13. tescmd/crypto/schnorr.py +191 -0
  14. tescmd/deploy/tailscale_serve.py +154 -0
  15. tescmd/models/__init__.py +0 -2
  16. tescmd/models/auth.py +19 -0
  17. tescmd/models/config.py +1 -0
  18. tescmd/models/energy.py +0 -9
  19. tescmd/protocol/session.py +10 -3
  20. tescmd/telemetry/__init__.py +19 -0
  21. tescmd/telemetry/dashboard.py +227 -0
  22. tescmd/telemetry/decoder.py +284 -0
  23. tescmd/telemetry/fields.py +248 -0
  24. tescmd/telemetry/flatbuf.py +162 -0
  25. tescmd/telemetry/protos/__init__.py +4 -0
  26. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  27. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  28. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  29. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  30. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  31. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  32. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  33. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  34. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  35. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  36. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  37. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  38. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  39. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  40. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  41. tescmd/telemetry/server.py +293 -0
  42. tescmd/telemetry/tailscale.py +300 -0
  43. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/METADATA +72 -35
  44. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/RECORD +47 -22
  45. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/WHEEL +0 -0
  46. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/entry_points.txt +0 -0
  47. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,23 @@
1
+ syntax = "proto3";
2
+
3
+ package telemetry.vehicle_error;
4
+
5
+ import "google/protobuf/timestamp.proto";
6
+
7
+ option go_package = "github.com/teslamotors/fleet-telemetry/protos";
8
+
9
+
10
+ // VehicleErrors is a collection of errors for a single vehicle.
11
+ message VehicleErrors {
12
+ repeated VehicleError errors = 1;
13
+ google.protobuf.Timestamp created_at = 2;
14
+ string vin = 3;
15
+ }
16
+
17
+ // VehicleError is a single error
18
+ message VehicleError {
19
+ google.protobuf.Timestamp created_at = 1;
20
+ string name = 2;
21
+ map<string, string> tags = 3;
22
+ string body = 4;
23
+ }
@@ -0,0 +1,44 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: vehicle_error.proto
5
+ # Protobuf Python Version: 6.31.1
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 6,
15
+ 31,
16
+ 1,
17
+ '',
18
+ 'vehicle_error.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
26
+
27
+
28
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13vehicle_error.proto\x12\x17telemetry.vehicle_error\x1a\x1fgoogle/protobuf/timestamp.proto\"\x83\x01\n\rVehicleErrors\x12\x35\n\x06\x65rrors\x18\x01 \x03(\x0b\x32%.telemetry.vehicle_error.VehicleError\x12.\n\ncreated_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0b\n\x03vin\x18\x03 \x01(\t\"\xc6\x01\n\x0cVehicleError\x12.\n\ncreated_at\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0c\n\x04name\x18\x02 \x01(\t\x12=\n\x04tags\x18\x03 \x03(\x0b\x32/.telemetry.vehicle_error.VehicleError.TagsEntry\x12\x0c\n\x04\x62ody\x18\x04 \x01(\t\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42/Z-github.com/teslamotors/fleet-telemetry/protosb\x06proto3')
29
+
30
+ _globals = globals()
31
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
32
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'vehicle_error_pb2', _globals)
33
+ if not _descriptor._USE_C_DESCRIPTORS:
34
+ _globals['DESCRIPTOR']._loaded_options = None
35
+ _globals['DESCRIPTOR']._serialized_options = b'Z-github.com/teslamotors/fleet-telemetry/protos'
36
+ _globals['_VEHICLEERROR_TAGSENTRY']._loaded_options = None
37
+ _globals['_VEHICLEERROR_TAGSENTRY']._serialized_options = b'8\001'
38
+ _globals['_VEHICLEERRORS']._serialized_start=82
39
+ _globals['_VEHICLEERRORS']._serialized_end=213
40
+ _globals['_VEHICLEERROR']._serialized_start=216
41
+ _globals['_VEHICLEERROR']._serialized_end=414
42
+ _globals['_VEHICLEERROR_TAGSENTRY']._serialized_start=371
43
+ _globals['_VEHICLEERROR_TAGSENTRY']._serialized_end=414
44
+ # @@protoc_insertion_point(module_scope)
@@ -0,0 +1,39 @@
1
+ import datetime
2
+
3
+ from google.protobuf import timestamp_pb2 as _timestamp_pb2
4
+ from google.protobuf.internal import containers as _containers
5
+ from google.protobuf import descriptor as _descriptor
6
+ from google.protobuf import message as _message
7
+ from collections.abc import Iterable as _Iterable, Mapping as _Mapping
8
+ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
9
+
10
+ DESCRIPTOR: _descriptor.FileDescriptor
11
+
12
+ class VehicleErrors(_message.Message):
13
+ __slots__ = ("errors", "created_at", "vin")
14
+ ERRORS_FIELD_NUMBER: _ClassVar[int]
15
+ CREATED_AT_FIELD_NUMBER: _ClassVar[int]
16
+ VIN_FIELD_NUMBER: _ClassVar[int]
17
+ errors: _containers.RepeatedCompositeFieldContainer[VehicleError]
18
+ created_at: _timestamp_pb2.Timestamp
19
+ vin: str
20
+ def __init__(self, errors: _Optional[_Iterable[_Union[VehicleError, _Mapping]]] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., vin: _Optional[str] = ...) -> None: ...
21
+
22
+ class VehicleError(_message.Message):
23
+ __slots__ = ("created_at", "name", "tags", "body")
24
+ class TagsEntry(_message.Message):
25
+ __slots__ = ("key", "value")
26
+ KEY_FIELD_NUMBER: _ClassVar[int]
27
+ VALUE_FIELD_NUMBER: _ClassVar[int]
28
+ key: str
29
+ value: str
30
+ def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
31
+ CREATED_AT_FIELD_NUMBER: _ClassVar[int]
32
+ NAME_FIELD_NUMBER: _ClassVar[int]
33
+ TAGS_FIELD_NUMBER: _ClassVar[int]
34
+ BODY_FIELD_NUMBER: _ClassVar[int]
35
+ created_at: _timestamp_pb2.Timestamp
36
+ name: str
37
+ tags: _containers.ScalarMap[str, str]
38
+ body: str
39
+ def __init__(self, created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., name: _Optional[str] = ..., tags: _Optional[_Mapping[str, str]] = ..., body: _Optional[str] = ...) -> None: ...
@@ -0,0 +1,22 @@
1
+ syntax = "proto3";
2
+
3
+ package telemetry.vehicle_metrics;
4
+
5
+ import "google/protobuf/timestamp.proto";
6
+
7
+ option go_package = "github.com/teslamotors/fleet-telemetry/protos";
8
+
9
+
10
+ // VehicleMetrics is a collection of metrics for a single vehicle.
11
+ message VehicleMetrics {
12
+ repeated Metric metrics = 1;
13
+ google.protobuf.Timestamp created_at = 2;
14
+ string vin = 3;
15
+ }
16
+
17
+ // Metric is a single metric value with a name, tags, and a value.
18
+ message Metric {
19
+ string name = 1;
20
+ map<string, string> tags = 2;
21
+ double value = 3;
22
+ }
@@ -0,0 +1,44 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: vehicle_metric.proto
5
+ # Protobuf Python Version: 6.31.1
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 6,
15
+ 31,
16
+ 1,
17
+ '',
18
+ 'vehicle_metric.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
26
+
27
+
28
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14vehicle_metric.proto\x12\x19telemetry.vehicle_metrics\x1a\x1fgoogle/protobuf/timestamp.proto\"\x81\x01\n\x0eVehicleMetrics\x12\x32\n\x07metrics\x18\x01 \x03(\x0b\x32!.telemetry.vehicle_metrics.Metric\x12.\n\ncreated_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0b\n\x03vin\x18\x03 \x01(\t\"\x8d\x01\n\x06Metric\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x39\n\x04tags\x18\x02 \x03(\x0b\x32+.telemetry.vehicle_metrics.Metric.TagsEntry\x12\r\n\x05value\x18\x03 \x01(\x01\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42/Z-github.com/teslamotors/fleet-telemetry/protosb\x06proto3')
29
+
30
+ _globals = globals()
31
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
32
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'vehicle_metric_pb2', _globals)
33
+ if not _descriptor._USE_C_DESCRIPTORS:
34
+ _globals['DESCRIPTOR']._loaded_options = None
35
+ _globals['DESCRIPTOR']._serialized_options = b'Z-github.com/teslamotors/fleet-telemetry/protos'
36
+ _globals['_METRIC_TAGSENTRY']._loaded_options = None
37
+ _globals['_METRIC_TAGSENTRY']._serialized_options = b'8\001'
38
+ _globals['_VEHICLEMETRICS']._serialized_start=85
39
+ _globals['_VEHICLEMETRICS']._serialized_end=214
40
+ _globals['_METRIC']._serialized_start=217
41
+ _globals['_METRIC']._serialized_end=358
42
+ _globals['_METRIC_TAGSENTRY']._serialized_start=315
43
+ _globals['_METRIC_TAGSENTRY']._serialized_end=358
44
+ # @@protoc_insertion_point(module_scope)
@@ -0,0 +1,37 @@
1
+ import datetime
2
+
3
+ from google.protobuf import timestamp_pb2 as _timestamp_pb2
4
+ from google.protobuf.internal import containers as _containers
5
+ from google.protobuf import descriptor as _descriptor
6
+ from google.protobuf import message as _message
7
+ from collections.abc import Iterable as _Iterable, Mapping as _Mapping
8
+ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
9
+
10
+ DESCRIPTOR: _descriptor.FileDescriptor
11
+
12
+ class VehicleMetrics(_message.Message):
13
+ __slots__ = ("metrics", "created_at", "vin")
14
+ METRICS_FIELD_NUMBER: _ClassVar[int]
15
+ CREATED_AT_FIELD_NUMBER: _ClassVar[int]
16
+ VIN_FIELD_NUMBER: _ClassVar[int]
17
+ metrics: _containers.RepeatedCompositeFieldContainer[Metric]
18
+ created_at: _timestamp_pb2.Timestamp
19
+ vin: str
20
+ def __init__(self, metrics: _Optional[_Iterable[_Union[Metric, _Mapping]]] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., vin: _Optional[str] = ...) -> None: ...
21
+
22
+ class Metric(_message.Message):
23
+ __slots__ = ("name", "tags", "value")
24
+ class TagsEntry(_message.Message):
25
+ __slots__ = ("key", "value")
26
+ KEY_FIELD_NUMBER: _ClassVar[int]
27
+ VALUE_FIELD_NUMBER: _ClassVar[int]
28
+ key: str
29
+ value: str
30
+ def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
31
+ NAME_FIELD_NUMBER: _ClassVar[int]
32
+ TAGS_FIELD_NUMBER: _ClassVar[int]
33
+ VALUE_FIELD_NUMBER: _ClassVar[int]
34
+ name: str
35
+ tags: _containers.ScalarMap[str, str]
36
+ value: float
37
+ def __init__(self, name: _Optional[str] = ..., tags: _Optional[_Mapping[str, str]] = ..., value: _Optional[float] = ...) -> None: ...
@@ -0,0 +1,293 @@
1
+ """Async WebSocket server for receiving Fleet Telemetry pushes.
2
+
3
+ Listens on all interfaces so Tailscale Funnel (which terminates TLS) can
4
+ proxy to the local plain-WebSocket port.
5
+
6
+ A lightweight TCP mux sits in front of the WebSocket server to handle
7
+ plain HTTP requests (HEAD, GET) that the ``websockets`` library rejects.
8
+ This is required because Tesla's Developer Portal sends HEAD requests to
9
+ validate Allowed Origin URLs, and browsers send GET requests for health
10
+ checks.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import logging
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Awaitable, Callable
22
+
23
+ import websockets.asyncio.server as ws_server
24
+
25
+ from tescmd.telemetry.decoder import TelemetryDecoder, TelemetryFrame
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _HTTP_200 = (
30
+ b"HTTP/1.1 200 OK\r\n"
31
+ b"Content-Type: text/plain\r\n"
32
+ b"Content-Length: 24\r\n"
33
+ b"\r\n"
34
+ b"tescmd telemetry server\n"
35
+ )
36
+
37
+ _HTTP_200_HEAD = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 0\r\n\r\n"
38
+
39
+ _WELL_KNOWN_PATH = "/.well-known/appspecific/com.tesla.3p.public-key.pem"
40
+
41
+
42
+ class TelemetryServer:
43
+ """Async WebSocket server that receives telemetry from vehicles.
44
+
45
+ Internally runs two servers:
46
+
47
+ * A **TCP mux** on ``0.0.0.0:{port}`` (the public-facing port that
48
+ the tunnel proxy connects to). It inspects the first HTTP request
49
+ line and either responds directly (HEAD / plain GET) or forwards
50
+ the connection to the internal WebSocket server.
51
+ * A **websockets** server on ``127.0.0.1:{port+1}`` that handles
52
+ actual WebSocket upgrade connections from vehicles.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ port: int,
58
+ decoder: TelemetryDecoder,
59
+ on_frame: Callable[[TelemetryFrame], Awaitable[None]],
60
+ *,
61
+ public_key_pem: str | None = None,
62
+ ) -> None:
63
+ self._port = port
64
+ self._ws_port = port + 1
65
+ self._decoder = decoder
66
+ self._on_frame = on_frame
67
+ self._public_key_pem = public_key_pem
68
+ self._ws_server: ws_server.Server | None = None
69
+ self._mux_server: asyncio.Server | None = None
70
+ self._active_ws: set[Any] = set()
71
+ self._connection_count = 0
72
+ self._frame_count = 0
73
+
74
+ async def start(self) -> None:
75
+ """Start the mux + WebSocket servers."""
76
+ try:
77
+ import websockets.asyncio.server as ws_server_mod
78
+ except ImportError as exc:
79
+ from tescmd.api.errors import ConfigError
80
+
81
+ raise ConfigError(
82
+ "websockets is required for telemetry streaming. "
83
+ "Install with: pip install tescmd[telemetry]"
84
+ ) from exc
85
+
86
+ # Internal WS server — only reachable from localhost
87
+ self._ws_server = await ws_server_mod.serve(
88
+ self._ws_handler,
89
+ host="127.0.0.1",
90
+ port=self._ws_port,
91
+ )
92
+
93
+ # Public-facing TCP mux — bind to all interfaces (IPv4 + IPv6).
94
+ self._mux_server = await asyncio.start_server(
95
+ self._mux_handler,
96
+ host=None,
97
+ port=self._port,
98
+ )
99
+
100
+ logger.info(
101
+ "Telemetry server listening on 0.0.0.0:%d (ws internal :%d)",
102
+ self._port,
103
+ self._ws_port,
104
+ )
105
+
106
+ async def stop(self) -> None:
107
+ """Gracefully shut down both servers and active connections."""
108
+ # Close active WebSocket connections first
109
+ for ws in list(self._active_ws):
110
+ with contextlib.suppress(Exception):
111
+ await ws.close()
112
+ self._active_ws.clear()
113
+
114
+ if self._mux_server is not None:
115
+ self._mux_server.close()
116
+ await self._mux_server.wait_closed()
117
+ self._mux_server = None
118
+
119
+ if self._ws_server is not None:
120
+ self._ws_server.close()
121
+ await self._ws_server.wait_closed()
122
+ self._ws_server = None
123
+
124
+ logger.info("Telemetry server stopped")
125
+
126
+ # ------------------------------------------------------------------
127
+ # TCP mux — inspects first request line, routes accordingly
128
+ # ------------------------------------------------------------------
129
+
130
+ async def _mux_handler(
131
+ self,
132
+ reader: asyncio.StreamReader,
133
+ writer: asyncio.StreamWriter,
134
+ ) -> None:
135
+ """Route an incoming TCP connection.
136
+
137
+ * **HEAD** — immediate HTTP 200 (Tesla origin validation).
138
+ * **GET without Upgrade** — immediate HTTP 200 (health check).
139
+ * **GET with Upgrade: websocket** — forward to internal WS server.
140
+ """
141
+ try:
142
+ first_line = await asyncio.wait_for(reader.readline(), timeout=10)
143
+ except (TimeoutError, ConnectionError):
144
+ writer.close()
145
+ return
146
+
147
+ if not first_line:
148
+ writer.close()
149
+ return
150
+
151
+ parts = first_line.decode("latin-1").strip().split(" ", 2)
152
+ method = parts[0].upper() if parts else ""
153
+ path = parts[1] if len(parts) > 1 else "/"
154
+
155
+ if method == "HEAD":
156
+ if path == _WELL_KNOWN_PATH and self._public_key_pem is not None:
157
+ pem_bytes = self._public_key_pem.encode()
158
+ writer.write(
159
+ f"HTTP/1.1 200 OK\r\n"
160
+ f"Content-Type: application/x-pem-file\r\n"
161
+ f"Content-Length: {len(pem_bytes)}\r\n"
162
+ f"\r\n".encode()
163
+ )
164
+ else:
165
+ writer.write(_HTTP_200_HEAD)
166
+ await writer.drain()
167
+ writer.close()
168
+ return
169
+
170
+ if method != "GET":
171
+ writer.write(_HTTP_200)
172
+ await writer.drain()
173
+ writer.close()
174
+ return
175
+
176
+ # Read remaining headers to check for WebSocket upgrade
177
+ raw_headers: list[bytes] = []
178
+ is_upgrade = False
179
+ try:
180
+ while True:
181
+ line = await asyncio.wait_for(reader.readline(), timeout=5)
182
+ raw_headers.append(line)
183
+ if line in (b"\r\n", b"\n", b""):
184
+ break
185
+ if line.lower().startswith(b"upgrade:") and b"websocket" in line.lower():
186
+ is_upgrade = True
187
+ except (TimeoutError, ConnectionError):
188
+ writer.close()
189
+ return
190
+
191
+ if not is_upgrade:
192
+ if path == _WELL_KNOWN_PATH and self._public_key_pem is not None:
193
+ pem_bytes = self._public_key_pem.encode()
194
+ writer.write(
195
+ f"HTTP/1.1 200 OK\r\n"
196
+ f"Content-Type: application/x-pem-file\r\n"
197
+ f"Content-Length: {len(pem_bytes)}\r\n"
198
+ f"\r\n".encode()
199
+ + pem_bytes
200
+ )
201
+ else:
202
+ writer.write(_HTTP_200)
203
+ await writer.drain()
204
+ writer.close()
205
+ return
206
+
207
+ # WebSocket upgrade — forward entire connection to internal WS server
208
+ try:
209
+ ws_reader, ws_writer = await asyncio.open_connection("127.0.0.1", self._ws_port)
210
+ except ConnectionError:
211
+ writer.close()
212
+ return
213
+
214
+ # Replay the already-read request line + headers
215
+ ws_writer.write(first_line)
216
+ for hdr in raw_headers:
217
+ ws_writer.write(hdr)
218
+ await ws_writer.drain()
219
+
220
+ # Bidirectional pipe until either side closes
221
+ await asyncio.gather(
222
+ self._pipe(reader, ws_writer),
223
+ self._pipe(ws_reader, writer),
224
+ )
225
+
226
+ @staticmethod
227
+ async def _pipe(
228
+ reader: asyncio.StreamReader,
229
+ writer: asyncio.StreamWriter,
230
+ ) -> None:
231
+ """Copy bytes from *reader* to *writer* until EOF."""
232
+ try:
233
+ while True:
234
+ data = await reader.read(65536)
235
+ if not data:
236
+ break
237
+ writer.write(data)
238
+ await writer.drain()
239
+ except (ConnectionError, asyncio.CancelledError):
240
+ pass
241
+ finally:
242
+ with contextlib.suppress(Exception):
243
+ writer.close()
244
+
245
+ # ------------------------------------------------------------------
246
+ # WebSocket handler
247
+ # ------------------------------------------------------------------
248
+
249
+ async def _ws_handler(self, websocket: Any) -> None:
250
+ """Handle a single vehicle WebSocket connection.
251
+
252
+ Receives binary frames, decodes via :class:`TelemetryDecoder`,
253
+ and dispatches to the ``on_frame`` callback. Malformed frames
254
+ are logged and skipped — never crash the server.
255
+ """
256
+ self._connection_count += 1
257
+ self._active_ws.add(websocket)
258
+ remote = getattr(websocket, "remote_address", ("unknown", 0))
259
+ logger.info("Vehicle connected: %s (total: %d)", remote, self._connection_count)
260
+
261
+ try:
262
+ async for message in websocket:
263
+ if isinstance(message, str):
264
+ # Tesla sends binary protobuf, but handle text gracefully
265
+ logger.debug("Received text frame (unexpected): %s", message[:200])
266
+ continue
267
+
268
+ try:
269
+ frame = self._decoder.decode(message)
270
+ self._frame_count += 1
271
+ await self._on_frame(frame)
272
+ except Exception:
273
+ logger.warning(
274
+ "Failed to decode telemetry frame (%d bytes)",
275
+ len(message),
276
+ exc_info=True,
277
+ )
278
+ except Exception:
279
+ logger.debug("Connection closed: %s", remote, exc_info=True)
280
+ finally:
281
+ self._active_ws.discard(websocket)
282
+ self._connection_count -= 1
283
+ logger.info("Vehicle disconnected: %s (remaining: %d)", remote, self._connection_count)
284
+
285
+ @property
286
+ def connection_count(self) -> int:
287
+ """Number of currently active WebSocket connections."""
288
+ return self._connection_count
289
+
290
+ @property
291
+ def frame_count(self) -> int:
292
+ """Total number of telemetry frames decoded since server start."""
293
+ return self._frame_count