tescmd 0.2.0__py3-none-any.whl → 0.4.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 +41 -4
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +5 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/auth/oauth.py +15 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +8 -1
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +255 -106
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +6 -7
- tescmd/cli/main.py +27 -8
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +147 -58
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +135 -462
- tescmd/deploy/github_pages.py +21 -2
- tescmd/deploy/tailscale_serve.py +96 -8
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/auth.py +5 -2
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +529 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +700 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +8 -5
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +9 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +4 -4
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +308 -129
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/server.py +26 -19
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +78 -16
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- tescmd-0.4.0.dist-info/METADATA +300 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
- tescmd-0.2.0.dist-info/METADATA +0 -495
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Transform telemetry data into OpenClaw ``req:agent`` event payloads.
|
|
2
|
+
|
|
3
|
+
Maps Tesla Fleet Telemetry field names to OpenClaw event types as defined
|
|
4
|
+
in the PRD:
|
|
5
|
+
|
|
6
|
+
- ``Location`` → ``location`` {latitude, longitude, heading, speed}
|
|
7
|
+
- ``Soc`` → ``battery`` {battery_level, range_miles}
|
|
8
|
+
- ``InsideTemp`` → ``inside_temp`` {inside_temp_f}
|
|
9
|
+
- ``OutsideTemp`` → ``outside_temp`` {outside_temp_f}
|
|
10
|
+
- ``VehicleSpeed`` → ``speed`` {speed_mph}
|
|
11
|
+
- ``ChargeState`` → ``charge_started`` / ``charge_complete`` / ``charge_stopped``
|
|
12
|
+
- ``DetailedChargeState`` → same as ChargeState
|
|
13
|
+
- ``Locked`` / ``SentryMode`` → ``security_changed``
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _celsius_to_fahrenheit(c: float) -> float:
|
|
23
|
+
return c * 9.0 / 5.0 + 32.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EventEmitter:
|
|
27
|
+
"""Stateless transformer: telemetry datum → OpenClaw req:agent payload.
|
|
28
|
+
|
|
29
|
+
Returns ``None`` for fields that don't map to an event type.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, client_id: str = "node-host") -> None:
|
|
33
|
+
self._client_id = client_id
|
|
34
|
+
|
|
35
|
+
def to_event(
|
|
36
|
+
self,
|
|
37
|
+
field_name: str,
|
|
38
|
+
value: Any,
|
|
39
|
+
vin: str,
|
|
40
|
+
timestamp: datetime | None = None,
|
|
41
|
+
) -> dict[str, Any] | None:
|
|
42
|
+
"""Convert a single telemetry datum to an OpenClaw event dict.
|
|
43
|
+
|
|
44
|
+
Returns ``None`` if the field doesn't map to an event type.
|
|
45
|
+
"""
|
|
46
|
+
ts = timestamp or datetime.now(UTC)
|
|
47
|
+
ts_iso = ts.isoformat()
|
|
48
|
+
|
|
49
|
+
payload = self._build_payload(field_name, value)
|
|
50
|
+
if payload is None:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
event_type = payload.pop("_event_type")
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
"method": "req:agent",
|
|
57
|
+
"params": {
|
|
58
|
+
"event_type": event_type,
|
|
59
|
+
"source": self._client_id,
|
|
60
|
+
"vin": vin,
|
|
61
|
+
"timestamp": ts_iso,
|
|
62
|
+
"data": payload,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def _build_payload(self, field_name: str, value: Any) -> dict[str, Any] | None:
|
|
67
|
+
"""Build event-specific payload. Returns None for unmapped fields."""
|
|
68
|
+
if field_name == "Location":
|
|
69
|
+
return self._location_payload(value)
|
|
70
|
+
if field_name == "Soc":
|
|
71
|
+
return self._battery_payload(value)
|
|
72
|
+
if field_name in ("InsideTemp", "OutsideTemp"):
|
|
73
|
+
return self._temp_payload(field_name, value)
|
|
74
|
+
if field_name == "VehicleSpeed":
|
|
75
|
+
return self._speed_payload(value)
|
|
76
|
+
if field_name in ("ChargeState", "DetailedChargeState"):
|
|
77
|
+
return self._charge_state_payload(value)
|
|
78
|
+
if field_name in ("Locked", "SentryMode"):
|
|
79
|
+
return self._security_payload(field_name, value)
|
|
80
|
+
if field_name == "BatteryLevel":
|
|
81
|
+
return self._battery_level_payload(value)
|
|
82
|
+
if field_name == "EstBatteryRange":
|
|
83
|
+
return self._range_payload(value)
|
|
84
|
+
if field_name == "Gear":
|
|
85
|
+
return self._gear_payload(value)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def _location_payload(self, value: Any) -> dict[str, Any] | None:
|
|
89
|
+
try:
|
|
90
|
+
return {
|
|
91
|
+
"_event_type": "location",
|
|
92
|
+
"latitude": float(value["latitude"]),
|
|
93
|
+
"longitude": float(value["longitude"]),
|
|
94
|
+
"heading": float(value.get("heading", 0)),
|
|
95
|
+
"speed": float(value.get("speed", 0)),
|
|
96
|
+
}
|
|
97
|
+
except (TypeError, KeyError, ValueError):
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def _battery_payload(self, value: Any) -> dict[str, Any] | None:
|
|
101
|
+
try:
|
|
102
|
+
return {
|
|
103
|
+
"_event_type": "battery",
|
|
104
|
+
"battery_level": float(value),
|
|
105
|
+
}
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def _temp_payload(self, field_name: str, value: Any) -> dict[str, Any] | None:
|
|
110
|
+
try:
|
|
111
|
+
temp_c = float(value)
|
|
112
|
+
temp_f = _celsius_to_fahrenheit(temp_c)
|
|
113
|
+
event_type = "inside_temp" if field_name == "InsideTemp" else "outside_temp"
|
|
114
|
+
key = f"{event_type}_f"
|
|
115
|
+
return {
|
|
116
|
+
"_event_type": event_type,
|
|
117
|
+
key: round(temp_f, 1),
|
|
118
|
+
}
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def _speed_payload(self, value: Any) -> dict[str, Any] | None:
|
|
123
|
+
try:
|
|
124
|
+
return {
|
|
125
|
+
"_event_type": "speed",
|
|
126
|
+
"speed_mph": float(value),
|
|
127
|
+
}
|
|
128
|
+
except (TypeError, ValueError):
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def _charge_state_payload(self, value: Any) -> dict[str, Any] | None:
|
|
132
|
+
state = str(value).lower()
|
|
133
|
+
if "charging" in state or state == "starting":
|
|
134
|
+
event_type = "charge_started"
|
|
135
|
+
elif "complete" in state:
|
|
136
|
+
event_type = "charge_complete"
|
|
137
|
+
elif "stopped" in state or "disconnected" in state:
|
|
138
|
+
event_type = "charge_stopped"
|
|
139
|
+
else:
|
|
140
|
+
event_type = "charge_state_changed"
|
|
141
|
+
return {
|
|
142
|
+
"_event_type": event_type,
|
|
143
|
+
"state": str(value),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def _security_payload(self, field_name: str, value: Any) -> dict[str, Any] | None:
|
|
147
|
+
return {
|
|
148
|
+
"_event_type": "security_changed",
|
|
149
|
+
"field": field_name.lower(),
|
|
150
|
+
"value": value,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def _battery_level_payload(self, value: Any) -> dict[str, Any] | None:
|
|
154
|
+
try:
|
|
155
|
+
return {
|
|
156
|
+
"_event_type": "battery",
|
|
157
|
+
"battery_level": float(value),
|
|
158
|
+
}
|
|
159
|
+
except (TypeError, ValueError):
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def _range_payload(self, value: Any) -> dict[str, Any] | None:
|
|
163
|
+
try:
|
|
164
|
+
return {
|
|
165
|
+
"_event_type": "battery",
|
|
166
|
+
"range_miles": float(value),
|
|
167
|
+
}
|
|
168
|
+
except (TypeError, ValueError):
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def _gear_payload(self, value: Any) -> dict[str, Any] | None:
|
|
172
|
+
return {
|
|
173
|
+
"_event_type": "gear_changed",
|
|
174
|
+
"gear": str(value),
|
|
175
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Dual-gate filter for telemetry field emission.
|
|
2
|
+
|
|
3
|
+
Both conditions must pass for a field to be emitted:
|
|
4
|
+
|
|
5
|
+
1. **Delta gate** — the value has changed beyond a field-specific granularity
|
|
6
|
+
threshold since the last emitted value.
|
|
7
|
+
2. **Throttle gate** — enough time has elapsed since the last emission.
|
|
8
|
+
|
|
9
|
+
Fields with ``granularity=0`` emit on any value change (state fields like
|
|
10
|
+
``ChargeState``, ``Locked``). Fields with ``throttle_seconds=0`` have no
|
|
11
|
+
time constraint.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from tescmd.openclaw.config import FieldFilter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
24
|
+
"""Haversine distance in meters between two WGS-84 coordinates.
|
|
25
|
+
|
|
26
|
+
Pure stdlib implementation — no external dependencies.
|
|
27
|
+
"""
|
|
28
|
+
r = 6_371_000.0 # Earth radius in meters
|
|
29
|
+
dlat = math.radians(lat2 - lat1)
|
|
30
|
+
dlon = math.radians(lon2 - lon1)
|
|
31
|
+
a = (
|
|
32
|
+
math.sin(dlat / 2) ** 2
|
|
33
|
+
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
|
|
34
|
+
)
|
|
35
|
+
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _numeric_delta(old: Any, new: Any) -> float:
|
|
39
|
+
"""Absolute difference between two numeric values."""
|
|
40
|
+
try:
|
|
41
|
+
return abs(float(new) - float(old))
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
# Non-numeric — treat as changed
|
|
44
|
+
return float("inf")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _location_delta(old: Any, new: Any) -> float:
|
|
48
|
+
"""Distance in meters between two location values.
|
|
49
|
+
|
|
50
|
+
Location values are expected as ``{"latitude": float, "longitude": float}``.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
return haversine(
|
|
54
|
+
old["latitude"],
|
|
55
|
+
old["longitude"],
|
|
56
|
+
new["latitude"],
|
|
57
|
+
new["longitude"],
|
|
58
|
+
)
|
|
59
|
+
except (TypeError, KeyError, ValueError):
|
|
60
|
+
return float("inf")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Fields that use location-based delta comparison
|
|
64
|
+
_LOCATION_FIELDS: frozenset[str] = frozenset({"Location"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DualGateFilter:
|
|
68
|
+
"""Dual-gate emission filter combining delta + throttle logic.
|
|
69
|
+
|
|
70
|
+
Usage::
|
|
71
|
+
|
|
72
|
+
filt = DualGateFilter(field_filters)
|
|
73
|
+
if filt.should_emit("Soc", 72, time.monotonic()):
|
|
74
|
+
filt.record_emit("Soc", 72, time.monotonic())
|
|
75
|
+
# ... emit the event
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, filters: dict[str, FieldFilter]) -> None:
|
|
79
|
+
self._filters = filters
|
|
80
|
+
self._last_values: dict[str, Any] = {}
|
|
81
|
+
self._last_emit_times: dict[str, float] = {}
|
|
82
|
+
|
|
83
|
+
def should_emit(self, field: str, value: Any, now: float) -> bool:
|
|
84
|
+
"""Check whether a field value passes both gates.
|
|
85
|
+
|
|
86
|
+
Returns ``True`` if the value should be emitted downstream.
|
|
87
|
+
"""
|
|
88
|
+
cfg = self._filters.get(field)
|
|
89
|
+
if cfg is None or not cfg.enabled:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
# Throttle gate: enforce minimum interval
|
|
93
|
+
if cfg.throttle_seconds > 0:
|
|
94
|
+
last_time = self._last_emit_times.get(field)
|
|
95
|
+
if last_time is not None and (now - last_time) < cfg.throttle_seconds:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
# Delta gate: value must have changed beyond granularity
|
|
99
|
+
last_value = self._last_values.get(field)
|
|
100
|
+
if last_value is None:
|
|
101
|
+
# First value for this field — always emit
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
if field in _LOCATION_FIELDS:
|
|
105
|
+
delta = _location_delta(last_value, value)
|
|
106
|
+
else:
|
|
107
|
+
delta = _numeric_delta(last_value, value)
|
|
108
|
+
|
|
109
|
+
# granularity=0 means any change triggers emission
|
|
110
|
+
if cfg.granularity == 0:
|
|
111
|
+
return bool(value != last_value)
|
|
112
|
+
|
|
113
|
+
return delta >= cfg.granularity
|
|
114
|
+
|
|
115
|
+
def record_emit(self, field: str, value: Any, now: float) -> None:
|
|
116
|
+
"""Record that a value was emitted (call after ``should_emit`` returns True)."""
|
|
117
|
+
self._last_values[field] = value
|
|
118
|
+
self._last_emit_times[field] = now
|
|
119
|
+
|
|
120
|
+
def reset(self) -> None:
|
|
121
|
+
"""Clear all tracked state."""
|
|
122
|
+
self._last_values.clear()
|
|
123
|
+
self._last_emit_times.clear()
|