tescmd 0.1.2__py3-none-any.whl → 0.3.1__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 (90) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +49 -5
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +13 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/api/vehicle.py +19 -1
  7. tescmd/auth/oauth.py +5 -1
  8. tescmd/auth/server.py +6 -1
  9. tescmd/auth/token_store.py +8 -1
  10. tescmd/cache/response_cache.py +11 -3
  11. tescmd/cli/_client.py +142 -20
  12. tescmd/cli/_options.py +2 -4
  13. tescmd/cli/auth.py +121 -11
  14. tescmd/cli/energy.py +2 -0
  15. tescmd/cli/key.py +149 -14
  16. tescmd/cli/main.py +70 -7
  17. tescmd/cli/mcp_cmd.py +153 -0
  18. tescmd/cli/nav.py +3 -1
  19. tescmd/cli/openclaw.py +169 -0
  20. tescmd/cli/security.py +7 -1
  21. tescmd/cli/serve.py +923 -0
  22. tescmd/cli/setup.py +244 -25
  23. tescmd/cli/sharing.py +2 -0
  24. tescmd/cli/status.py +1 -1
  25. tescmd/cli/trunk.py +8 -17
  26. tescmd/cli/user.py +16 -1
  27. tescmd/cli/vehicle.py +156 -20
  28. tescmd/crypto/__init__.py +3 -1
  29. tescmd/crypto/ecdh.py +9 -0
  30. tescmd/crypto/schnorr.py +191 -0
  31. tescmd/deploy/github_pages.py +8 -0
  32. tescmd/deploy/tailscale_serve.py +154 -0
  33. tescmd/mcp/__init__.py +7 -0
  34. tescmd/mcp/server.py +648 -0
  35. tescmd/models/__init__.py +0 -2
  36. tescmd/models/auth.py +24 -2
  37. tescmd/models/config.py +1 -0
  38. tescmd/models/energy.py +0 -9
  39. tescmd/openclaw/__init__.py +23 -0
  40. tescmd/openclaw/bridge.py +330 -0
  41. tescmd/openclaw/config.py +167 -0
  42. tescmd/openclaw/dispatcher.py +522 -0
  43. tescmd/openclaw/emitter.py +175 -0
  44. tescmd/openclaw/filters.py +123 -0
  45. tescmd/openclaw/gateway.py +687 -0
  46. tescmd/openclaw/telemetry_store.py +53 -0
  47. tescmd/output/rich_output.py +46 -14
  48. tescmd/protocol/commands.py +2 -2
  49. tescmd/protocol/encoder.py +16 -13
  50. tescmd/protocol/payloads.py +132 -11
  51. tescmd/protocol/session.py +18 -8
  52. tescmd/protocol/signer.py +3 -17
  53. tescmd/telemetry/__init__.py +28 -0
  54. tescmd/telemetry/cache_sink.py +154 -0
  55. tescmd/telemetry/csv_sink.py +180 -0
  56. tescmd/telemetry/dashboard.py +227 -0
  57. tescmd/telemetry/decoder.py +284 -0
  58. tescmd/telemetry/fanout.py +49 -0
  59. tescmd/telemetry/fields.py +427 -0
  60. tescmd/telemetry/flatbuf.py +162 -0
  61. tescmd/telemetry/mapper.py +239 -0
  62. tescmd/telemetry/protos/__init__.py +4 -0
  63. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  64. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  65. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  66. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  67. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  68. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  69. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  70. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  71. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  72. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  73. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  74. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  75. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  76. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  77. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  78. tescmd/telemetry/server.py +300 -0
  79. tescmd/telemetry/setup.py +468 -0
  80. tescmd/telemetry/tailscale.py +300 -0
  81. tescmd/telemetry/tui.py +1716 -0
  82. tescmd/triggers/__init__.py +18 -0
  83. tescmd/triggers/manager.py +264 -0
  84. tescmd/triggers/models.py +93 -0
  85. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
  86. tescmd-0.3.1.dist-info/RECORD +128 -0
  87. tescmd-0.1.2.dist-info/RECORD +0 -81
  88. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  89. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  90. {tescmd-0.1.2.dist-info → tescmd-0.3.1.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,300 @@
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
+ if not (0 <= port <= 65534):
64
+ raise ValueError(f"Port must be between 0 and 65534, got {port}")
65
+ self._port = port
66
+ self._ws_port = port + 1
67
+ self._decoder = decoder
68
+ self._on_frame = on_frame
69
+ self._public_key_pem = public_key_pem
70
+ self._ws_server: ws_server.Server | None = None
71
+ self._mux_server: asyncio.Server | None = None
72
+ self._active_ws: set[Any] = set()
73
+ self._connection_count = 0
74
+ self._frame_count = 0
75
+
76
+ async def start(self) -> None:
77
+ """Start the mux + WebSocket servers."""
78
+ import websockets.asyncio.server as ws_server_mod
79
+
80
+ # Internal WS server — only reachable from localhost
81
+ try:
82
+ self._ws_server = await ws_server_mod.serve(
83
+ self._ws_handler,
84
+ host="127.0.0.1",
85
+ port=self._ws_port,
86
+ )
87
+ except OSError as exc:
88
+ raise OSError(
89
+ f"Cannot bind internal WebSocket server to port {self._ws_port}: {exc}"
90
+ ) from exc
91
+
92
+ # Public-facing TCP mux — bind to all interfaces (IPv4 + IPv6).
93
+ try:
94
+ self._mux_server = await asyncio.start_server(
95
+ self._mux_handler,
96
+ host=None,
97
+ port=self._port,
98
+ )
99
+ except OSError as exc:
100
+ # Clean up the already-started WS server before re-raising
101
+ if self._ws_server is not None:
102
+ self._ws_server.close()
103
+ await self._ws_server.wait_closed()
104
+ self._ws_server = None
105
+ raise OSError(f"Cannot bind mux server to port {self._port}: {exc}") from exc
106
+
107
+ logger.info(
108
+ "Telemetry server listening on 0.0.0.0:%d (ws internal :%d)",
109
+ self._port,
110
+ self._ws_port,
111
+ )
112
+
113
+ async def stop(self) -> None:
114
+ """Gracefully shut down both servers and active connections."""
115
+ # Close active WebSocket connections first
116
+ for ws in list(self._active_ws):
117
+ with contextlib.suppress(Exception):
118
+ await ws.close()
119
+ self._active_ws.clear()
120
+
121
+ if self._mux_server is not None:
122
+ self._mux_server.close()
123
+ await self._mux_server.wait_closed()
124
+ self._mux_server = None
125
+
126
+ if self._ws_server is not None:
127
+ self._ws_server.close()
128
+ await self._ws_server.wait_closed()
129
+ self._ws_server = None
130
+
131
+ logger.info("Telemetry server stopped")
132
+
133
+ # ------------------------------------------------------------------
134
+ # TCP mux — inspects first request line, routes accordingly
135
+ # ------------------------------------------------------------------
136
+
137
+ async def _mux_handler(
138
+ self,
139
+ reader: asyncio.StreamReader,
140
+ writer: asyncio.StreamWriter,
141
+ ) -> None:
142
+ """Route an incoming TCP connection.
143
+
144
+ * **HEAD** — immediate HTTP 200 (Tesla origin validation).
145
+ * **GET without Upgrade** — immediate HTTP 200 (health check).
146
+ * **GET with Upgrade: websocket** — forward to internal WS server.
147
+ """
148
+ try:
149
+ first_line = await asyncio.wait_for(reader.readline(), timeout=10)
150
+ except (TimeoutError, ConnectionError):
151
+ writer.close()
152
+ return
153
+
154
+ if not first_line:
155
+ writer.close()
156
+ return
157
+
158
+ parts = first_line.decode("latin-1").strip().split(" ", 2)
159
+ method = parts[0].upper() if parts else ""
160
+ path = parts[1] if len(parts) > 1 else "/"
161
+
162
+ if method == "HEAD":
163
+ if path == _WELL_KNOWN_PATH and self._public_key_pem is not None:
164
+ pem_bytes = self._public_key_pem.encode()
165
+ writer.write(
166
+ f"HTTP/1.1 200 OK\r\n"
167
+ f"Content-Type: application/x-pem-file\r\n"
168
+ f"Content-Length: {len(pem_bytes)}\r\n"
169
+ f"\r\n".encode()
170
+ )
171
+ else:
172
+ writer.write(_HTTP_200_HEAD)
173
+ await writer.drain()
174
+ writer.close()
175
+ return
176
+
177
+ if method != "GET":
178
+ writer.write(_HTTP_200)
179
+ await writer.drain()
180
+ writer.close()
181
+ return
182
+
183
+ # Read remaining headers to check for WebSocket upgrade
184
+ raw_headers: list[bytes] = []
185
+ is_upgrade = False
186
+ try:
187
+ while True:
188
+ line = await asyncio.wait_for(reader.readline(), timeout=5)
189
+ raw_headers.append(line)
190
+ if line in (b"\r\n", b"\n", b""):
191
+ break
192
+ if line.lower().startswith(b"upgrade:") and b"websocket" in line.lower():
193
+ is_upgrade = True
194
+ except (TimeoutError, ConnectionError):
195
+ writer.close()
196
+ return
197
+
198
+ if not is_upgrade:
199
+ if path == _WELL_KNOWN_PATH and self._public_key_pem is not None:
200
+ pem_bytes = self._public_key_pem.encode()
201
+ writer.write(
202
+ f"HTTP/1.1 200 OK\r\n"
203
+ f"Content-Type: application/x-pem-file\r\n"
204
+ f"Content-Length: {len(pem_bytes)}\r\n"
205
+ f"\r\n".encode()
206
+ + pem_bytes
207
+ )
208
+ else:
209
+ writer.write(_HTTP_200)
210
+ await writer.drain()
211
+ writer.close()
212
+ return
213
+
214
+ # WebSocket upgrade — forward entire connection to internal WS server
215
+ try:
216
+ ws_reader, ws_writer = await asyncio.open_connection("127.0.0.1", self._ws_port)
217
+ except ConnectionError:
218
+ writer.close()
219
+ return
220
+
221
+ # Replay the already-read request line + headers
222
+ ws_writer.write(first_line)
223
+ for hdr in raw_headers:
224
+ ws_writer.write(hdr)
225
+ await ws_writer.drain()
226
+
227
+ # Bidirectional pipe until either side closes
228
+ await asyncio.gather(
229
+ self._pipe(reader, ws_writer),
230
+ self._pipe(ws_reader, writer),
231
+ )
232
+
233
+ @staticmethod
234
+ async def _pipe(
235
+ reader: asyncio.StreamReader,
236
+ writer: asyncio.StreamWriter,
237
+ ) -> None:
238
+ """Copy bytes from *reader* to *writer* until EOF."""
239
+ try:
240
+ while True:
241
+ data = await reader.read(65536)
242
+ if not data:
243
+ break
244
+ writer.write(data)
245
+ await writer.drain()
246
+ except (ConnectionError, asyncio.CancelledError):
247
+ pass
248
+ finally:
249
+ with contextlib.suppress(Exception):
250
+ writer.close()
251
+
252
+ # ------------------------------------------------------------------
253
+ # WebSocket handler
254
+ # ------------------------------------------------------------------
255
+
256
+ async def _ws_handler(self, websocket: Any) -> None:
257
+ """Handle a single vehicle WebSocket connection.
258
+
259
+ Receives binary frames, decodes via :class:`TelemetryDecoder`,
260
+ and dispatches to the ``on_frame`` callback. Malformed frames
261
+ are logged and skipped — never crash the server.
262
+ """
263
+ self._connection_count += 1
264
+ self._active_ws.add(websocket)
265
+ remote = getattr(websocket, "remote_address", ("unknown", 0))
266
+ logger.info("Vehicle connected: %s (total: %d)", remote, self._connection_count)
267
+
268
+ try:
269
+ async for message in websocket:
270
+ if isinstance(message, str):
271
+ # Tesla sends binary protobuf, but handle text gracefully
272
+ logger.debug("Received text frame (unexpected): %s", message[:200])
273
+ continue
274
+
275
+ try:
276
+ frame = self._decoder.decode(message)
277
+ self._frame_count += 1
278
+ await self._on_frame(frame)
279
+ except Exception:
280
+ logger.warning(
281
+ "Failed to decode telemetry frame (%d bytes)",
282
+ len(message),
283
+ exc_info=True,
284
+ )
285
+ except Exception:
286
+ logger.debug("Connection closed: %s", remote, exc_info=True)
287
+ finally:
288
+ self._active_ws.discard(websocket)
289
+ self._connection_count -= 1
290
+ logger.info("Vehicle disconnected: %s (remaining: %d)", remote, self._connection_count)
291
+
292
+ @property
293
+ def connection_count(self) -> int:
294
+ """Number of currently active WebSocket connections."""
295
+ return self._connection_count
296
+
297
+ @property
298
+ def frame_count(self) -> int:
299
+ """Total number of telemetry frames decoded since server start."""
300
+ return self._frame_count