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,154 @@
1
+ """Cache-warming telemetry sink.
2
+
3
+ Receives decoded telemetry frames, translates field values via
4
+ :class:`~tescmd.telemetry.mapper.TelemetryMapper`, and merges them into
5
+ a :class:`~tescmd.cache.response_cache.ResponseCache`. This keeps the
6
+ MCP tool cache warm so read operations are free while telemetry is active.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from tescmd.cache.response_cache import ResponseCache
17
+ from tescmd.telemetry.decoder import TelemetryFrame
18
+ from tescmd.telemetry.mapper import TelemetryMapper
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _deep_set(target: dict[str, Any], dotted_path: str, value: Any) -> None:
24
+ """Set a value in a nested dict using a dotted path.
25
+
26
+ Creates intermediate dicts as needed.
27
+
28
+ >>> d: dict[str, Any] = {}
29
+ >>> _deep_set(d, "charge_state.battery_level", 80)
30
+ >>> d
31
+ {'charge_state': {'battery_level': 80}}
32
+ """
33
+ keys = dotted_path.split(".")
34
+ for key in keys[:-1]:
35
+ if key not in target or not isinstance(target[key], dict):
36
+ target[key] = {}
37
+ target = target[key]
38
+ target[keys[-1]] = value
39
+
40
+
41
+ def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> None:
42
+ """Recursively merge *overlay* into *base* (mutates *base*).
43
+
44
+ Dict values are merged recursively; all other values are overwritten.
45
+ """
46
+ for key, value in overlay.items():
47
+ if key in base and isinstance(base[key], dict) and isinstance(value, dict):
48
+ _deep_merge(base[key], value)
49
+ else:
50
+ base[key] = value
51
+
52
+
53
+ class CacheSink:
54
+ """Telemetry sink that warms the response cache.
55
+
56
+ Accumulates telemetry updates in a buffer and flushes them to the
57
+ :class:`ResponseCache` at a configurable interval. While active,
58
+ cached data is given a generous TTL so MCP reads don't trigger API
59
+ requests.
60
+
61
+ Parameters:
62
+ cache: The file-based response cache to write into.
63
+ mapper: Field mapper for telemetry → VehicleData translation.
64
+ vin: Vehicle VIN to cache data for.
65
+ flush_interval: Minimum seconds between disk flushes.
66
+ telemetry_ttl: TTL in seconds for cache entries while streaming.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ cache: ResponseCache,
72
+ mapper: TelemetryMapper,
73
+ vin: str,
74
+ *,
75
+ flush_interval: float = 1.0,
76
+ telemetry_ttl: int = 120,
77
+ ) -> None:
78
+ self._cache = cache
79
+ self._mapper = mapper
80
+ self._vin = vin
81
+ self._flush_interval = flush_interval
82
+ self._telemetry_ttl = telemetry_ttl
83
+ self._pending: dict[str, Any] = {}
84
+ self._last_flush: float = float("-inf")
85
+ self._frame_count: int = 0
86
+ self._field_count: int = 0
87
+
88
+ @property
89
+ def frame_count(self) -> int:
90
+ """Total frames processed."""
91
+ return self._frame_count
92
+
93
+ @property
94
+ def field_count(self) -> int:
95
+ """Total field updates applied to cache."""
96
+ return self._field_count
97
+
98
+ @property
99
+ def pending_count(self) -> int:
100
+ """Number of buffered updates not yet flushed."""
101
+ return self._count_leaves(self._pending)
102
+
103
+ async def on_frame(self, frame: TelemetryFrame) -> None:
104
+ """Process a decoded telemetry frame into the cache buffer.
105
+
106
+ Skips frames for other VINs. Maps each datum through the
107
+ :class:`TelemetryMapper` and buffers the results for the next
108
+ flush cycle.
109
+ """
110
+ if frame.vin != self._vin:
111
+ return
112
+
113
+ self._frame_count += 1
114
+
115
+ for datum in frame.data:
116
+ for path, value in self._mapper.map(datum.field_name, datum.value):
117
+ _deep_set(self._pending, path, value)
118
+ self._field_count += 1
119
+
120
+ now = time.monotonic()
121
+ if now - self._last_flush >= self._flush_interval:
122
+ self.flush()
123
+ self._last_flush = now
124
+
125
+ def flush(self) -> None:
126
+ """Merge buffered updates into the response cache immediately.
127
+
128
+ Called automatically during ``on_frame`` when the flush interval
129
+ has elapsed, or manually for cleanup / testing.
130
+ """
131
+ if not self._pending:
132
+ return
133
+
134
+ # Read-modify-write: merge into existing cached data
135
+ existing = self._cache.get(self._vin)
136
+ blob: dict[str, Any] = existing.data if existing else {"vin": self._vin, "state": "online"}
137
+
138
+ _deep_merge(blob, self._pending)
139
+ self._cache.put(self._vin, blob, ttl=self._telemetry_ttl)
140
+ self._cache.put_wake_state(self._vin, "online", ttl=self._telemetry_ttl)
141
+ self._pending.clear()
142
+
143
+ logger.debug("Cache flushed for %s (%d fields)", self._vin, self._field_count)
144
+
145
+ @staticmethod
146
+ def _count_leaves(d: dict[str, Any]) -> int:
147
+ """Count leaf (non-dict) values in a nested dict."""
148
+ count = 0
149
+ for v in d.values():
150
+ if isinstance(v, dict):
151
+ count += CacheSink._count_leaves(v)
152
+ else:
153
+ count += 1
154
+ return count
@@ -0,0 +1,180 @@
1
+ """Wide-format CSV log sink for vehicle telemetry.
2
+
3
+ Writes one row per telemetry frame with one column per subscribed field.
4
+ The header extends dynamically as new fields are discovered.
5
+
6
+ Output format::
7
+
8
+ timestamp,vin,BatteryLevel,ChargeLimitSoc,InsideTemp,...
9
+ 2026-02-01T12:34:55Z,5YJ3E...,80,90,22.5,...
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import csv
15
+ import logging
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import IO, TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from tescmd.telemetry.decoder import TelemetryFrame
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Fixed columns that always appear first.
26
+ _FIXED_COLUMNS = ("timestamp", "vin")
27
+
28
+ # Flush to disk every N frames for crash safety.
29
+ _FLUSH_INTERVAL = 10
30
+
31
+
32
+ def create_log_path(vin: str, config_dir: Path | None = None) -> Path:
33
+ """Build a timestamped CSV log path under the config directory.
34
+
35
+ Returns a path like ``~/.config/tescmd/logs/serve-{VIN}-{YYYYMMDD-HHMMSS}.csv``.
36
+ Creates the ``logs/`` subdirectory if it does not exist.
37
+ """
38
+ if config_dir is None:
39
+ config_dir = Path.home() / ".config" / "tescmd"
40
+ log_dir = config_dir / "logs"
41
+ log_dir.mkdir(parents=True, exist_ok=True)
42
+
43
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
44
+ return log_dir / f"serve-{vin}-{stamp}.csv"
45
+
46
+
47
+ class CSVLogSink:
48
+ """Telemetry sink that writes wide-format CSV.
49
+
50
+ Raw API values are written without unit conversion (Celsius, miles, bar)
51
+ so the CSV is a faithful record of what the vehicle reported.
52
+
53
+ Parameters:
54
+ path: Destination CSV file path.
55
+ vin: Only log frames matching this VIN (``None`` = log all).
56
+ """
57
+
58
+ def __init__(self, path: Path, vin: str | None = None) -> None:
59
+ import atexit
60
+
61
+ self._path = path
62
+ self._vin = vin
63
+ self._fh: IO[str] | None = None
64
+ self._writer: csv.DictWriter[str] | None = None
65
+ self._fieldnames: list[str] = list(_FIXED_COLUMNS)
66
+ self._frame_count: int = 0
67
+ self._since_flush: int = 0
68
+ atexit.register(self.close)
69
+
70
+ # -- Properties -----------------------------------------------------------
71
+
72
+ @property
73
+ def log_path(self) -> Path:
74
+ """The CSV file path."""
75
+ return self._path
76
+
77
+ @property
78
+ def frame_count(self) -> int:
79
+ """Total frames written."""
80
+ return self._frame_count
81
+
82
+ # -- Sink callback --------------------------------------------------------
83
+
84
+ async def on_frame(self, frame: TelemetryFrame) -> None:
85
+ """Write a single telemetry frame as a CSV row.
86
+
87
+ Called by :class:`~tescmd.telemetry.fanout.FrameFanout`.
88
+ Frames for other VINs are silently skipped.
89
+ """
90
+ if self._vin is not None and frame.vin != self._vin:
91
+ return
92
+
93
+ # Build the row dict from frame data.
94
+ row: dict[str, Any] = {
95
+ "timestamp": frame.created_at.isoformat(),
96
+ "vin": frame.vin,
97
+ }
98
+ for datum in frame.data:
99
+ value = datum.value
100
+ # Flatten location dicts to a string representation.
101
+ if isinstance(value, dict):
102
+ value = ";".join(f"{k}={v}" for k, v in value.items())
103
+ row[datum.field_name] = value
104
+
105
+ # Discover new fields and rewrite the header if needed.
106
+ new_fields = [f for f in row if f not in self._fieldnames]
107
+ if new_fields:
108
+ self._fieldnames.extend(new_fields)
109
+ if self._fh is not None:
110
+ self._rewrite_header()
111
+
112
+ # Lazily open the file on the first frame.
113
+ if self._fh is None:
114
+ self._open()
115
+
116
+ assert self._writer is not None
117
+ self._writer.writerow(row)
118
+ self._frame_count += 1
119
+ self._since_flush += 1
120
+
121
+ if self._since_flush >= _FLUSH_INTERVAL:
122
+ self._flush()
123
+
124
+ # -- Lifecycle ------------------------------------------------------------
125
+
126
+ def close(self) -> None:
127
+ """Flush and close the CSV file."""
128
+ if self._fh is not None:
129
+ self._flush()
130
+ self._fh.close()
131
+ self._fh = None
132
+ self._writer = None
133
+
134
+ # -- Internals ------------------------------------------------------------
135
+
136
+ def _open(self) -> None:
137
+ """Open the CSV file and write the initial header."""
138
+ self._fh = open(self._path, "w", newline="", encoding="utf-8") # noqa: SIM115
139
+ self._writer = csv.DictWriter(
140
+ self._fh,
141
+ fieldnames=self._fieldnames,
142
+ extrasaction="ignore",
143
+ )
144
+ self._writer.writeheader()
145
+
146
+ def _rewrite_header(self) -> None:
147
+ """Rewrite the file with the expanded header, preserving existing rows.
148
+
149
+ This is called when a new telemetry field is discovered mid-stream.
150
+ The approach is: read existing content, rewrite with new fieldnames.
151
+ """
152
+ if self._fh is None:
153
+ return
154
+
155
+ self._fh.flush()
156
+ self._fh.close()
157
+
158
+ # Read existing rows.
159
+ rows: list[dict[str, str]] = []
160
+ with open(self._path, newline="", encoding="utf-8") as f:
161
+ reader = csv.DictReader(f)
162
+ for r in reader:
163
+ rows.append(r)
164
+
165
+ # Rewrite with expanded header.
166
+ self._fh = open(self._path, "w", newline="", encoding="utf-8") # noqa: SIM115
167
+ self._writer = csv.DictWriter(
168
+ self._fh,
169
+ fieldnames=self._fieldnames,
170
+ extrasaction="ignore",
171
+ )
172
+ self._writer.writeheader()
173
+ for r in rows:
174
+ self._writer.writerow(r)
175
+
176
+ def _flush(self) -> None:
177
+ """Flush the file handle to disk."""
178
+ if self._fh is not None:
179
+ self._fh.flush()
180
+ self._since_flush = 0
@@ -158,8 +158,8 @@ class TelemetryDashboard:
158
158
  temp_fields = {
159
159
  "InsideTemp",
160
160
  "OutsideTemp",
161
- "DriverTempSetting",
162
- "PassengerTempSetting",
161
+ "HvacLeftTemperatureRequest",
162
+ "HvacRightTemperatureRequest",
163
163
  "ModuleTempMax",
164
164
  "ModuleTempMin",
165
165
  }
@@ -173,7 +173,7 @@ class TelemetryDashboard:
173
173
  "Odometer",
174
174
  "EstBatteryRange",
175
175
  "IdealBatteryRange",
176
- "RatedBatteryRange",
176
+ "RatedRange",
177
177
  "MilesToArrival",
178
178
  }
179
179
  if field_name in distance_fields and isinstance(value, (int, float)):
@@ -182,7 +182,7 @@ class TelemetryDashboard:
182
182
  return f"{value:.1f} mi"
183
183
 
184
184
  # Speed fields (API returns mph)
185
- speed_fields = {"VehicleSpeed", "CruiseSetSpeed", "MaxSpeedLimit"}
185
+ speed_fields = {"VehicleSpeed", "CruiseSetSpeed", "CurrentLimitMph"}
186
186
  if field_name in speed_fields and isinstance(value, (int, float)):
187
187
  if self._units.distance == DistanceUnit.KM:
188
188
  return f"{value * 1.60934:.0f} km/h"
@@ -0,0 +1,49 @@
1
+ """Fan-out dispatcher for telemetry frames.
2
+
3
+ Multiplexes a single ``on_frame`` callback to N sinks, each error-isolated.
4
+ One sink failing does not affect others.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Awaitable, Callable
14
+
15
+ from tescmd.telemetry.decoder import TelemetryFrame
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class FrameFanout:
21
+ """Fan-out dispatcher: delivers each telemetry frame to all registered sinks."""
22
+
23
+ def __init__(self) -> None:
24
+ self._sinks: list[Callable[[TelemetryFrame], Awaitable[None]]] = []
25
+
26
+ def add_sink(self, callback: Callable[[TelemetryFrame], Awaitable[None]]) -> None:
27
+ """Register a sink to receive telemetry frames."""
28
+ self._sinks.append(callback)
29
+
30
+ @property
31
+ def sink_count(self) -> int:
32
+ """Number of registered sinks."""
33
+ return len(self._sinks)
34
+
35
+ def has_sinks(self) -> bool:
36
+ """Return ``True`` if at least one sink is registered."""
37
+ return len(self._sinks) > 0
38
+
39
+ async def on_frame(self, frame: TelemetryFrame) -> None:
40
+ """Dispatch *frame* to all registered sinks.
41
+
42
+ Each sink is called independently. If a sink raises, the exception
43
+ is logged and the remaining sinks still receive the frame.
44
+ """
45
+ for sink in self._sinks:
46
+ try:
47
+ await sink(frame)
48
+ except Exception:
49
+ logger.warning("Sink %s failed for frame", sink, exc_info=True)