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.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +49 -5
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +13 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/api/vehicle.py +19 -1
- tescmd/auth/oauth.py +5 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +11 -3
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +121 -11
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +70 -7
- 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 +244 -25
- 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 +156 -20
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/github_pages.py +8 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +24 -2
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +522 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +687 -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 +18 -8
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +28 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +427 -0
- tescmd/telemetry/flatbuf.py +162 -0
- tescmd/telemetry/mapper.py +239 -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 +300 -0
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +300 -0
- 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.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
- tescmd-0.3.1.dist-info/RECORD +128 -0
- tescmd-0.1.2.dist-info/RECORD +0 -81
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
tescmd/models/auth.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import base64
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
+
import os as _os
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
from pydantic import BaseModel
|
|
@@ -17,6 +18,7 @@ VEHICLE_SCOPES: list[str] = [
|
|
|
17
18
|
"vehicle_device_data",
|
|
18
19
|
"vehicle_cmds",
|
|
19
20
|
"vehicle_charging_cmds",
|
|
21
|
+
"vehicle_location",
|
|
20
22
|
]
|
|
21
23
|
|
|
22
24
|
ENERGY_SCOPES: list[str] = [
|
|
@@ -43,10 +45,10 @@ DEFAULT_SCOPES: list[str] = [
|
|
|
43
45
|
*USER_SCOPES,
|
|
44
46
|
]
|
|
45
47
|
|
|
46
|
-
DEFAULT_PORT: int = 8085
|
|
48
|
+
DEFAULT_PORT: int = int(_os.environ.get("TESCMD_OAUTH_PORT", "8085"))
|
|
47
49
|
DEFAULT_REDIRECT_URI: str = f"http://localhost:{DEFAULT_PORT}/callback"
|
|
48
50
|
|
|
49
|
-
AUTH_BASE_URL: str = "https://auth.tesla.com"
|
|
51
|
+
AUTH_BASE_URL: str = _os.environ.get("TESLA_AUTH_BASE_URL", "https://auth.tesla.com")
|
|
50
52
|
AUTHORIZE_URL: str = f"{AUTH_BASE_URL}/oauth2/v3/authorize"
|
|
51
53
|
TOKEN_URL: str = f"{AUTH_BASE_URL}/oauth2/v3/token"
|
|
52
54
|
|
|
@@ -96,9 +98,29 @@ def decode_jwt_scopes(token: str) -> list[str] | None:
|
|
|
96
98
|
scp = payload.get("scp")
|
|
97
99
|
if isinstance(scp, list):
|
|
98
100
|
return [str(s) for s in scp]
|
|
101
|
+
if isinstance(scp, str):
|
|
102
|
+
return scp.split()
|
|
99
103
|
return None
|
|
100
104
|
|
|
101
105
|
|
|
106
|
+
def decode_jwt_payload(token: str) -> dict[str, Any] | None:
|
|
107
|
+
"""Decode the full JWT payload without verifying the signature.
|
|
108
|
+
|
|
109
|
+
Returns the payload dict, or ``None`` if the token isn't a valid JWT.
|
|
110
|
+
"""
|
|
111
|
+
parts = token.split(".")
|
|
112
|
+
if len(parts) != 3:
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
116
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
117
|
+
payload: dict[str, Any] = json.loads(payload_bytes)
|
|
118
|
+
except (ValueError, json.JSONDecodeError):
|
|
119
|
+
logger.debug("Failed to decode JWT payload")
|
|
120
|
+
return None
|
|
121
|
+
return payload
|
|
122
|
+
|
|
123
|
+
|
|
102
124
|
class AuthConfig(BaseModel):
|
|
103
125
|
"""Configuration needed to start an OAuth flow."""
|
|
104
126
|
|
tescmd/models/config.py
CHANGED
|
@@ -43,6 +43,7 @@ class AppSettings(BaseSettings):
|
|
|
43
43
|
profile: str = "default"
|
|
44
44
|
setup_tier: str | None = None
|
|
45
45
|
github_repo: str | None = None
|
|
46
|
+
hosting_method: str | None = None # "github" | "tailscale" | None (manual)
|
|
46
47
|
access_token: str | None = None
|
|
47
48
|
refresh_token: str | None = None
|
|
48
49
|
|
tescmd/models/energy.py
CHANGED
|
@@ -9,15 +9,6 @@ from pydantic import BaseModel, ConfigDict
|
|
|
9
9
|
_EXTRA_ALLOW = ConfigDict(extra="allow")
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
class EnergySite(BaseModel):
|
|
13
|
-
model_config = _EXTRA_ALLOW
|
|
14
|
-
|
|
15
|
-
energy_site_id: int
|
|
16
|
-
resource_type: str | None = None
|
|
17
|
-
site_name: str | None = None
|
|
18
|
-
gateway_id: str | None = None
|
|
19
|
-
|
|
20
|
-
|
|
21
12
|
class LiveStatus(BaseModel):
|
|
22
13
|
model_config = _EXTRA_ALLOW
|
|
23
14
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""OpenClaw integration — bridge Fleet Telemetry to an OpenClaw Gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tescmd.openclaw.bridge import TelemetryBridge
|
|
6
|
+
from tescmd.openclaw.config import BridgeConfig, FieldFilter, NodeCapabilities
|
|
7
|
+
from tescmd.openclaw.dispatcher import CommandDispatcher
|
|
8
|
+
from tescmd.openclaw.emitter import EventEmitter
|
|
9
|
+
from tescmd.openclaw.filters import DualGateFilter
|
|
10
|
+
from tescmd.openclaw.gateway import GatewayClient
|
|
11
|
+
from tescmd.openclaw.telemetry_store import TelemetryStore
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BridgeConfig",
|
|
15
|
+
"CommandDispatcher",
|
|
16
|
+
"DualGateFilter",
|
|
17
|
+
"EventEmitter",
|
|
18
|
+
"FieldFilter",
|
|
19
|
+
"GatewayClient",
|
|
20
|
+
"NodeCapabilities",
|
|
21
|
+
"TelemetryBridge",
|
|
22
|
+
"TelemetryStore",
|
|
23
|
+
]
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Telemetry bridge orchestrator.
|
|
2
|
+
|
|
3
|
+
Wires the pipeline: TelemetryServer.on_frame → DualGateFilter → EventEmitter
|
|
4
|
+
→ GatewayClient. Passed as ``on_frame`` callback to ``TelemetryServer``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from tescmd.cli.main import AppContext
|
|
17
|
+
from tescmd.openclaw.config import BridgeConfig
|
|
18
|
+
from tescmd.openclaw.emitter import EventEmitter
|
|
19
|
+
from tescmd.openclaw.filters import DualGateFilter
|
|
20
|
+
from tescmd.openclaw.gateway import GatewayClient
|
|
21
|
+
from tescmd.openclaw.telemetry_store import TelemetryStore
|
|
22
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
23
|
+
from tescmd.triggers.manager import TriggerManager
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_RECONNECT_BASE = 5.0
|
|
28
|
+
_RECONNECT_MAX = 120.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TelemetryBridge:
|
|
32
|
+
"""Orchestrates filter → emit → send for each telemetry frame.
|
|
33
|
+
|
|
34
|
+
Usage::
|
|
35
|
+
|
|
36
|
+
bridge = TelemetryBridge(config, gateway, filt, emitter)
|
|
37
|
+
# Pass bridge.on_frame as the telemetry server callback
|
|
38
|
+
server = TelemetryServer(port, decoder, bridge.on_frame, ...)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
gateway: GatewayClient,
|
|
44
|
+
filt: DualGateFilter,
|
|
45
|
+
emitter: EventEmitter,
|
|
46
|
+
*,
|
|
47
|
+
dry_run: bool = False,
|
|
48
|
+
telemetry_store: TelemetryStore | None = None,
|
|
49
|
+
trigger_manager: TriggerManager | None = None,
|
|
50
|
+
vin: str = "",
|
|
51
|
+
client_id: str = "node-host",
|
|
52
|
+
) -> None:
|
|
53
|
+
self._gateway = gateway
|
|
54
|
+
self._filter = filt
|
|
55
|
+
self._emitter = emitter
|
|
56
|
+
self._dry_run = dry_run
|
|
57
|
+
self._telemetry_store = telemetry_store
|
|
58
|
+
self._trigger_manager = trigger_manager
|
|
59
|
+
self._vin = vin
|
|
60
|
+
self._client_id = client_id
|
|
61
|
+
self._event_count = 0
|
|
62
|
+
self._drop_count = 0
|
|
63
|
+
self._last_event_time: float | None = None
|
|
64
|
+
self._first_frame_received = False
|
|
65
|
+
self._reconnect_at: float = 0.0
|
|
66
|
+
self._reconnect_backoff: float = _RECONNECT_BASE
|
|
67
|
+
self._shutting_down = False
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def event_count(self) -> int:
|
|
71
|
+
return self._event_count
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def drop_count(self) -> int:
|
|
75
|
+
return self._drop_count
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def last_event_time(self) -> float | None:
|
|
79
|
+
return self._last_event_time
|
|
80
|
+
|
|
81
|
+
async def _maybe_reconnect(self) -> None:
|
|
82
|
+
"""Attempt gateway reconnection with exponential backoff."""
|
|
83
|
+
if self._shutting_down:
|
|
84
|
+
return
|
|
85
|
+
now = time.monotonic()
|
|
86
|
+
if now < self._reconnect_at:
|
|
87
|
+
return
|
|
88
|
+
logger.info("Attempting OpenClaw gateway reconnection...")
|
|
89
|
+
try:
|
|
90
|
+
await self._gateway.connect()
|
|
91
|
+
self._reconnect_backoff = _RECONNECT_BASE
|
|
92
|
+
logger.info("Reconnected to OpenClaw gateway")
|
|
93
|
+
except Exception:
|
|
94
|
+
self._reconnect_at = now + self._reconnect_backoff
|
|
95
|
+
logger.warning(
|
|
96
|
+
"Reconnection failed — next attempt in %.0fs",
|
|
97
|
+
self._reconnect_backoff,
|
|
98
|
+
)
|
|
99
|
+
self._reconnect_backoff = min(self._reconnect_backoff * 2, _RECONNECT_MAX)
|
|
100
|
+
|
|
101
|
+
def _build_lifecycle_event(self, event_type: str) -> dict[str, Any]:
|
|
102
|
+
"""Build a ``req:agent`` lifecycle event (connecting/disconnecting)."""
|
|
103
|
+
return {
|
|
104
|
+
"method": "req:agent",
|
|
105
|
+
"params": {
|
|
106
|
+
"event_type": event_type,
|
|
107
|
+
"source": self._client_id,
|
|
108
|
+
"vin": self._vin,
|
|
109
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
110
|
+
"data": {},
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def make_trigger_push_callback(self) -> Any:
|
|
115
|
+
"""Return an async callback that pushes trigger notifications to the gateway.
|
|
116
|
+
|
|
117
|
+
Suitable for passing to ``TriggerManager.add_on_fire()``. Returns
|
|
118
|
+
``None`` when in dry-run mode (caller should skip registration).
|
|
119
|
+
"""
|
|
120
|
+
if self._dry_run:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
gateway = self._gateway
|
|
124
|
+
client_id = self._client_id
|
|
125
|
+
|
|
126
|
+
async def _push_trigger_notification(n: Any) -> None:
|
|
127
|
+
if gateway.is_connected:
|
|
128
|
+
try:
|
|
129
|
+
await gateway.send_event(
|
|
130
|
+
{
|
|
131
|
+
"method": "req:agent",
|
|
132
|
+
"params": {
|
|
133
|
+
"event_type": "trigger.fired",
|
|
134
|
+
"source": client_id,
|
|
135
|
+
"vin": n.vin,
|
|
136
|
+
"timestamp": n.fired_at.isoformat(),
|
|
137
|
+
"data": n.model_dump(mode="json"),
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
except Exception:
|
|
142
|
+
logger.warning(
|
|
143
|
+
"Failed to push trigger notification (trigger=%s field=%s)",
|
|
144
|
+
n.trigger_id,
|
|
145
|
+
n.field,
|
|
146
|
+
exc_info=True,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return _push_trigger_notification
|
|
150
|
+
|
|
151
|
+
async def send_disconnecting(self) -> None:
|
|
152
|
+
"""Send a ``node.disconnecting`` lifecycle event to the gateway.
|
|
153
|
+
|
|
154
|
+
Called during shutdown before the gateway connection is closed.
|
|
155
|
+
Silently ignored if the gateway is not connected.
|
|
156
|
+
"""
|
|
157
|
+
self._shutting_down = True
|
|
158
|
+
if self._dry_run:
|
|
159
|
+
return
|
|
160
|
+
event = self._build_lifecycle_event("node.disconnecting")
|
|
161
|
+
try:
|
|
162
|
+
await self._gateway.send_event(event)
|
|
163
|
+
logger.info("Sent node.disconnecting event")
|
|
164
|
+
except Exception:
|
|
165
|
+
logger.warning("Failed to send disconnecting event", exc_info=True)
|
|
166
|
+
|
|
167
|
+
async def on_frame(self, frame: TelemetryFrame) -> None:
|
|
168
|
+
"""Process a decoded telemetry frame through the filter pipeline.
|
|
169
|
+
|
|
170
|
+
For each datum in the frame, check the dual-gate filter. If it
|
|
171
|
+
passes, transform to an OpenClaw event and send to the gateway.
|
|
172
|
+
Failed sends are logged and discarded — never crash the server.
|
|
173
|
+
If the gateway is disconnected, a reconnection attempt is made
|
|
174
|
+
(with exponential backoff) before dropping events.
|
|
175
|
+
"""
|
|
176
|
+
now = time.monotonic()
|
|
177
|
+
|
|
178
|
+
# Send node.connected lifecycle event on the very first frame.
|
|
179
|
+
if not self._first_frame_received:
|
|
180
|
+
self._first_frame_received = True
|
|
181
|
+
if not self._dry_run and self._gateway.is_connected:
|
|
182
|
+
lifecycle_event = self._build_lifecycle_event("node.connected")
|
|
183
|
+
try:
|
|
184
|
+
await self._gateway.send_event(lifecycle_event)
|
|
185
|
+
logger.info("Sent node.connected event")
|
|
186
|
+
except Exception:
|
|
187
|
+
logger.warning("Failed to send connected event", exc_info=True)
|
|
188
|
+
|
|
189
|
+
for datum in frame.data:
|
|
190
|
+
if not self._filter.should_emit(datum.field_name, datum.value, now):
|
|
191
|
+
self._drop_count += 1
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
event = self._emitter.to_event(
|
|
195
|
+
field_name=datum.field_name,
|
|
196
|
+
value=datum.value,
|
|
197
|
+
vin=frame.vin,
|
|
198
|
+
timestamp=frame.created_at,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if event is None:
|
|
202
|
+
self._drop_count += 1
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
self._filter.record_emit(datum.field_name, datum.value, now)
|
|
206
|
+
self._event_count += 1
|
|
207
|
+
self._last_event_time = now
|
|
208
|
+
|
|
209
|
+
if self._dry_run:
|
|
210
|
+
import json
|
|
211
|
+
|
|
212
|
+
print(json.dumps(event, default=str), flush=True)
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
if not self._gateway.is_connected:
|
|
216
|
+
await self._maybe_reconnect()
|
|
217
|
+
if not self._gateway.is_connected:
|
|
218
|
+
self._drop_count += 1
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
await self._gateway.send_event(event)
|
|
223
|
+
logger.info("Sent %s event for %s", datum.field_name, frame.vin)
|
|
224
|
+
except Exception:
|
|
225
|
+
logger.warning(
|
|
226
|
+
"Failed to send event for %s — discarding",
|
|
227
|
+
datum.field_name,
|
|
228
|
+
exc_info=True,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Update telemetry store and evaluate triggers for ALL datums
|
|
232
|
+
# (not just filtered ones) so read handlers always see the latest
|
|
233
|
+
# values and triggers fire on every change.
|
|
234
|
+
if self._telemetry_store is not None or self._trigger_manager is not None:
|
|
235
|
+
for datum in frame.data:
|
|
236
|
+
prev_snap = (
|
|
237
|
+
self._telemetry_store.get(datum.field_name)
|
|
238
|
+
if self._telemetry_store is not None
|
|
239
|
+
else None
|
|
240
|
+
)
|
|
241
|
+
prev_value = prev_snap.value if prev_snap is not None else None
|
|
242
|
+
|
|
243
|
+
if self._telemetry_store is not None:
|
|
244
|
+
self._telemetry_store.update(datum.field_name, datum.value, frame.created_at)
|
|
245
|
+
|
|
246
|
+
if self._trigger_manager is not None:
|
|
247
|
+
await self._trigger_manager.evaluate(
|
|
248
|
+
datum.field_name, datum.value, prev_value, frame.created_at
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# -- Pipeline factory -------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class OpenClawPipeline:
|
|
257
|
+
"""Holds all components of an assembled OpenClaw pipeline."""
|
|
258
|
+
|
|
259
|
+
gateway: GatewayClient
|
|
260
|
+
bridge: TelemetryBridge
|
|
261
|
+
telemetry_store: TelemetryStore
|
|
262
|
+
dispatcher: Any # CommandDispatcher — avoids circular import
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def build_openclaw_pipeline(
|
|
266
|
+
config: BridgeConfig,
|
|
267
|
+
vin: str,
|
|
268
|
+
app_ctx: AppContext,
|
|
269
|
+
*,
|
|
270
|
+
trigger_manager: TriggerManager | None = None,
|
|
271
|
+
dry_run: bool = False,
|
|
272
|
+
) -> OpenClawPipeline:
|
|
273
|
+
"""Construct the full OpenClaw pipeline from a :class:`BridgeConfig`.
|
|
274
|
+
|
|
275
|
+
Returns an :class:`OpenClawPipeline` containing the gateway client,
|
|
276
|
+
telemetry bridge, telemetry store, and command dispatcher — ready
|
|
277
|
+
to be connected and wired into the telemetry fanout.
|
|
278
|
+
|
|
279
|
+
After calling this, the caller should:
|
|
280
|
+
|
|
281
|
+
1. ``await pipeline.gateway.connect_with_backoff(...)`` (unless dry-run)
|
|
282
|
+
2. ``fanout.add_sink(pipeline.bridge.on_frame)``
|
|
283
|
+
3. Optionally register the trigger push callback via
|
|
284
|
+
``pipeline.bridge.make_trigger_push_callback()``
|
|
285
|
+
"""
|
|
286
|
+
from tescmd.openclaw.dispatcher import CommandDispatcher
|
|
287
|
+
from tescmd.openclaw.emitter import EventEmitter
|
|
288
|
+
from tescmd.openclaw.filters import DualGateFilter
|
|
289
|
+
from tescmd.openclaw.gateway import GatewayClient
|
|
290
|
+
from tescmd.openclaw.telemetry_store import TelemetryStore
|
|
291
|
+
|
|
292
|
+
telemetry_store = TelemetryStore()
|
|
293
|
+
dispatcher = CommandDispatcher(
|
|
294
|
+
vin=vin,
|
|
295
|
+
app_ctx=app_ctx,
|
|
296
|
+
telemetry_store=telemetry_store,
|
|
297
|
+
trigger_manager=trigger_manager,
|
|
298
|
+
)
|
|
299
|
+
filt = DualGateFilter(config.telemetry)
|
|
300
|
+
emitter = EventEmitter(client_id=config.client_id)
|
|
301
|
+
|
|
302
|
+
from tescmd import __version__
|
|
303
|
+
|
|
304
|
+
gateway = GatewayClient(
|
|
305
|
+
config.gateway_url,
|
|
306
|
+
token=config.gateway_token,
|
|
307
|
+
client_id=config.client_id,
|
|
308
|
+
client_version=config.client_version,
|
|
309
|
+
display_name=f"tescmd-{__version__}-{vin}",
|
|
310
|
+
model_identifier=vin,
|
|
311
|
+
capabilities=config.capabilities,
|
|
312
|
+
on_request=dispatcher.dispatch,
|
|
313
|
+
)
|
|
314
|
+
bridge = TelemetryBridge(
|
|
315
|
+
gateway,
|
|
316
|
+
filt,
|
|
317
|
+
emitter,
|
|
318
|
+
dry_run=dry_run,
|
|
319
|
+
telemetry_store=telemetry_store,
|
|
320
|
+
vin=vin,
|
|
321
|
+
client_id=config.client_id,
|
|
322
|
+
trigger_manager=trigger_manager,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
return OpenClawPipeline(
|
|
326
|
+
gateway=gateway,
|
|
327
|
+
bridge=bridge,
|
|
328
|
+
telemetry_store=telemetry_store,
|
|
329
|
+
dispatcher=dispatcher,
|
|
330
|
+
)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Bridge configuration for OpenClaw Gateway integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NodeCapabilities(BaseModel):
|
|
13
|
+
"""Advertised capabilities for the OpenClaw node role.
|
|
14
|
+
|
|
15
|
+
Maps to the gateway connect schema fields:
|
|
16
|
+
- ``caps``: broad capability categories (e.g. ``"location"``, ``"climate"``)
|
|
17
|
+
- ``commands``: specific method names the node can handle
|
|
18
|
+
- ``permissions``: per-command permission booleans
|
|
19
|
+
|
|
20
|
+
The ``reads`` and ``writes`` helpers provide a logical grouping that
|
|
21
|
+
gets flattened into the gateway-native fields via :meth:`to_connect_params`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
reads: list[str] = [
|
|
25
|
+
"location.get",
|
|
26
|
+
"battery.get",
|
|
27
|
+
"temperature.get",
|
|
28
|
+
"speed.get",
|
|
29
|
+
"charge_state.get",
|
|
30
|
+
"security.get",
|
|
31
|
+
# Trigger reads
|
|
32
|
+
"trigger.list",
|
|
33
|
+
"trigger.poll",
|
|
34
|
+
]
|
|
35
|
+
writes: list[str] = [
|
|
36
|
+
"door.lock",
|
|
37
|
+
"door.unlock",
|
|
38
|
+
"climate.on",
|
|
39
|
+
"climate.off",
|
|
40
|
+
"climate.set_temp",
|
|
41
|
+
"charge.start",
|
|
42
|
+
"charge.stop",
|
|
43
|
+
"charge.set_limit",
|
|
44
|
+
"trunk.open",
|
|
45
|
+
"frunk.open",
|
|
46
|
+
"flash_lights",
|
|
47
|
+
"honk_horn",
|
|
48
|
+
"sentry.on",
|
|
49
|
+
"sentry.off",
|
|
50
|
+
# Trigger writes
|
|
51
|
+
"trigger.create",
|
|
52
|
+
"trigger.delete",
|
|
53
|
+
# Convenience trigger aliases
|
|
54
|
+
"cabin_temp.trigger",
|
|
55
|
+
"outside_temp.trigger",
|
|
56
|
+
"battery.trigger",
|
|
57
|
+
"location.trigger",
|
|
58
|
+
# Meta-dispatch
|
|
59
|
+
"system.run",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def all_commands(self) -> list[str]:
|
|
64
|
+
"""All command method names (reads + writes), deduplicated."""
|
|
65
|
+
return list(dict.fromkeys(self.reads + self.writes))
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def caps(self) -> list[str]:
|
|
69
|
+
"""Unique capability categories derived from command prefixes."""
|
|
70
|
+
seen: dict[str, None] = {}
|
|
71
|
+
for cmd in self.all_commands:
|
|
72
|
+
category = cmd.split(".")[0] if "." in cmd else cmd
|
|
73
|
+
seen.setdefault(category, None)
|
|
74
|
+
return list(seen)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def permissions(self) -> dict[str, bool]:
|
|
78
|
+
"""Per-command permissions (all ``True`` for advertised commands)."""
|
|
79
|
+
return {cmd: True for cmd in self.all_commands}
|
|
80
|
+
|
|
81
|
+
def to_connect_params(self) -> dict[str, Any]:
|
|
82
|
+
"""Return the gateway-native connect param fields."""
|
|
83
|
+
return {
|
|
84
|
+
"caps": self.caps,
|
|
85
|
+
"commands": self.all_commands,
|
|
86
|
+
"permissions": self.permissions,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FieldFilter(BaseModel):
|
|
91
|
+
"""Per-field filter configuration for the dual-gate filter."""
|
|
92
|
+
|
|
93
|
+
enabled: bool = True
|
|
94
|
+
granularity: float = Field(default=0.0, ge=0)
|
|
95
|
+
"""Delta threshold — units depend on field type (meters, percent, degrees, etc.).
|
|
96
|
+
|
|
97
|
+
A value of ``0`` means any change triggers emission.
|
|
98
|
+
"""
|
|
99
|
+
throttle_seconds: float = Field(default=1.0, ge=0)
|
|
100
|
+
"""Minimum seconds between emissions for this field."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Default filter configurations per PRD
|
|
104
|
+
_DEFAULT_FILTERS: dict[str, FieldFilter] = {
|
|
105
|
+
"Location": FieldFilter(granularity=50.0, throttle_seconds=1.0),
|
|
106
|
+
"Soc": FieldFilter(granularity=5.0, throttle_seconds=10.0),
|
|
107
|
+
"InsideTemp": FieldFilter(granularity=5.0, throttle_seconds=30.0),
|
|
108
|
+
"OutsideTemp": FieldFilter(granularity=5.0, throttle_seconds=30.0),
|
|
109
|
+
"VehicleSpeed": FieldFilter(granularity=5.0, throttle_seconds=2.0),
|
|
110
|
+
"ChargeState": FieldFilter(granularity=0.0, throttle_seconds=0.0),
|
|
111
|
+
"DetailedChargeState": FieldFilter(granularity=0.0, throttle_seconds=0.0),
|
|
112
|
+
"Locked": FieldFilter(granularity=0.0, throttle_seconds=0.0),
|
|
113
|
+
"SentryMode": FieldFilter(granularity=0.0, throttle_seconds=0.0),
|
|
114
|
+
"BatteryLevel": FieldFilter(granularity=1.0, throttle_seconds=10.0),
|
|
115
|
+
"EstBatteryRange": FieldFilter(granularity=5.0, throttle_seconds=30.0),
|
|
116
|
+
"Odometer": FieldFilter(granularity=1.0, throttle_seconds=60.0),
|
|
117
|
+
"Gear": FieldFilter(granularity=0.0, throttle_seconds=0.0),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class BridgeConfig(BaseModel):
|
|
122
|
+
"""Configuration for the OpenClaw telemetry bridge.
|
|
123
|
+
|
|
124
|
+
Loaded from ``~/.config/tescmd/bridge.json``, CLI flags, or environment
|
|
125
|
+
variables.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
gateway_url: str = Field(
|
|
129
|
+
default_factory=lambda: __import__("os").environ.get(
|
|
130
|
+
"OPENCLAW_GATEWAY_URL", "ws://127.0.0.1:18789"
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
gateway_token: str | None = Field(default=None)
|
|
134
|
+
client_id: str = "node-host"
|
|
135
|
+
client_version: str | None = None
|
|
136
|
+
telemetry: dict[str, FieldFilter] = Field(default_factory=lambda: dict(_DEFAULT_FILTERS))
|
|
137
|
+
capabilities: NodeCapabilities = Field(default_factory=NodeCapabilities)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def load(cls, path: Path | str | None = None) -> BridgeConfig:
|
|
141
|
+
"""Load configuration from a JSON file.
|
|
142
|
+
|
|
143
|
+
Falls back to defaults if the file does not exist.
|
|
144
|
+
"""
|
|
145
|
+
resolved = (
|
|
146
|
+
Path("~/.config/tescmd/bridge.json").expanduser() if path is None else Path(path)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if not resolved.exists():
|
|
150
|
+
return cls()
|
|
151
|
+
|
|
152
|
+
raw = json.loads(resolved.read_text(encoding="utf-8"))
|
|
153
|
+
return cls.model_validate(raw)
|
|
154
|
+
|
|
155
|
+
def merge_overrides(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
gateway_url: str | None = None,
|
|
159
|
+
gateway_token: str | None = None,
|
|
160
|
+
) -> BridgeConfig:
|
|
161
|
+
"""Return a new config with CLI flag overrides applied."""
|
|
162
|
+
data: dict[str, Any] = self.model_dump()
|
|
163
|
+
if gateway_url is not None:
|
|
164
|
+
data["gateway_url"] = gateway_url
|
|
165
|
+
if gateway_token is not None:
|
|
166
|
+
data["gateway_token"] = gateway_token
|
|
167
|
+
return BridgeConfig.model_validate(data)
|