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.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +8 -1
- tescmd/api/errors.py +8 -0
- tescmd/api/vehicle.py +19 -1
- tescmd/cache/response_cache.py +3 -2
- tescmd/cli/auth.py +30 -2
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +44 -0
- tescmd/cli/setup.py +230 -22
- tescmd/cli/vehicle.py +464 -1
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +19 -0
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- tescmd/protocol/session.py +10 -3
- tescmd/telemetry/__init__.py +19 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fields.py +248 -0
- tescmd/telemetry/flatbuf.py +162 -0
- tescmd/telemetry/protos/__init__.py +4 -0
- tescmd/telemetry/protos/vehicle_alert.proto +31 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
- tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
- tescmd/telemetry/protos/vehicle_data.proto +768 -0
- tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
- tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
- tescmd/telemetry/protos/vehicle_error.proto +23 -0
- tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
- tescmd/telemetry/protos/vehicle_metric.proto +22 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
- tescmd/telemetry/server.py +293 -0
- tescmd/telemetry/tailscale.py +300 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/METADATA +72 -35
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/RECORD +47 -22
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/entry_points.txt +0 -0
- {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
|