tescmd 0.2.0__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 (61) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +41 -4
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +5 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/auth/oauth.py +5 -1
  7. tescmd/auth/server.py +6 -1
  8. tescmd/auth/token_store.py +8 -1
  9. tescmd/cache/response_cache.py +8 -1
  10. tescmd/cli/_client.py +142 -20
  11. tescmd/cli/_options.py +2 -4
  12. tescmd/cli/auth.py +96 -14
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/main.py +27 -8
  15. tescmd/cli/mcp_cmd.py +153 -0
  16. tescmd/cli/nav.py +3 -1
  17. tescmd/cli/openclaw.py +169 -0
  18. tescmd/cli/security.py +7 -1
  19. tescmd/cli/serve.py +923 -0
  20. tescmd/cli/setup.py +18 -7
  21. tescmd/cli/sharing.py +2 -0
  22. tescmd/cli/status.py +1 -1
  23. tescmd/cli/trunk.py +8 -17
  24. tescmd/cli/user.py +16 -1
  25. tescmd/cli/vehicle.py +135 -462
  26. tescmd/deploy/github_pages.py +8 -0
  27. tescmd/mcp/__init__.py +7 -0
  28. tescmd/mcp/server.py +648 -0
  29. tescmd/models/auth.py +5 -2
  30. tescmd/openclaw/__init__.py +23 -0
  31. tescmd/openclaw/bridge.py +330 -0
  32. tescmd/openclaw/config.py +167 -0
  33. tescmd/openclaw/dispatcher.py +522 -0
  34. tescmd/openclaw/emitter.py +175 -0
  35. tescmd/openclaw/filters.py +123 -0
  36. tescmd/openclaw/gateway.py +687 -0
  37. tescmd/openclaw/telemetry_store.py +53 -0
  38. tescmd/output/rich_output.py +46 -14
  39. tescmd/protocol/commands.py +2 -2
  40. tescmd/protocol/encoder.py +16 -13
  41. tescmd/protocol/payloads.py +132 -11
  42. tescmd/protocol/session.py +8 -5
  43. tescmd/protocol/signer.py +3 -17
  44. tescmd/telemetry/__init__.py +9 -0
  45. tescmd/telemetry/cache_sink.py +154 -0
  46. tescmd/telemetry/csv_sink.py +180 -0
  47. tescmd/telemetry/dashboard.py +4 -4
  48. tescmd/telemetry/fanout.py +49 -0
  49. tescmd/telemetry/fields.py +308 -129
  50. tescmd/telemetry/mapper.py +239 -0
  51. tescmd/telemetry/server.py +26 -19
  52. tescmd/telemetry/setup.py +468 -0
  53. tescmd/telemetry/tui.py +1716 -0
  54. tescmd/triggers/__init__.py +18 -0
  55. tescmd/triggers/manager.py +264 -0
  56. tescmd/triggers/models.py +93 -0
  57. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/METADATA +80 -32
  58. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/RECORD +61 -39
  59. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  60. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  61. {tescmd-0.2.0.dist-info → tescmd-0.3.1.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()