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.
- 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 +5 -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 +96 -14
- tescmd/cli/energy.py +2 -0
- 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 +18 -7
- 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 +8 -0
- 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 +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 +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/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/METADATA +80 -32
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/RECORD +61 -39
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""Command dispatcher for inbound OpenClaw gateway requests.
|
|
2
|
+
|
|
3
|
+
Maps OpenClaw method names (e.g. ``door.lock``, ``battery.get``) to
|
|
4
|
+
Tesla Fleet API calls. Read handlers check the :class:`TelemetryStore`
|
|
5
|
+
first; write handlers call the command API, auto-wake once on
|
|
6
|
+
:class:`VehicleAsleepError`, and invalidate the cache on success.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from tescmd.api.errors import VehicleAsleepError
|
|
16
|
+
from tescmd.cli._client import (
|
|
17
|
+
check_command_guards,
|
|
18
|
+
get_command_api,
|
|
19
|
+
get_vehicle_api,
|
|
20
|
+
invalidate_cache_for_vin,
|
|
21
|
+
)
|
|
22
|
+
from tescmd.triggers.models import TriggerCondition, TriggerDefinition, TriggerOperator
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from tescmd.cli.main import AppContext
|
|
26
|
+
from tescmd.openclaw.telemetry_store import TelemetryStore
|
|
27
|
+
from tescmd.triggers.manager import TriggerManager
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# API snake_case → OpenClaw dot notation aliases for system.run
|
|
32
|
+
_METHOD_ALIASES: dict[str, str] = {
|
|
33
|
+
"door_lock": "door.lock",
|
|
34
|
+
"door_unlock": "door.unlock",
|
|
35
|
+
"auto_conditioning_start": "climate.on",
|
|
36
|
+
"auto_conditioning_stop": "climate.off",
|
|
37
|
+
"set_temps": "climate.set_temp",
|
|
38
|
+
"charge_start": "charge.start",
|
|
39
|
+
"charge_stop": "charge.stop",
|
|
40
|
+
"set_charge_limit": "charge.set_limit",
|
|
41
|
+
"actuate_trunk": "trunk.open",
|
|
42
|
+
"flash_lights": "flash_lights",
|
|
43
|
+
"honk_horn": "honk_horn",
|
|
44
|
+
"share": "nav.send",
|
|
45
|
+
"navigation_gps_request": "nav.gps",
|
|
46
|
+
"navigation_sc_request": "nav.supercharger",
|
|
47
|
+
"navigation_waypoints_request": "nav.waypoints",
|
|
48
|
+
"trigger_homelink": "homelink.trigger",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CommandDispatcher:
|
|
53
|
+
"""Dispatch OpenClaw inbound requests to the Tesla Fleet API.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
vin:
|
|
58
|
+
Vehicle Identification Number to target.
|
|
59
|
+
app_ctx:
|
|
60
|
+
CLI application context (provides API client builders and cache).
|
|
61
|
+
telemetry_store:
|
|
62
|
+
Optional in-memory store of recent telemetry values. When
|
|
63
|
+
available, read handlers check here first before hitting the API.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
vin: str,
|
|
69
|
+
app_ctx: AppContext,
|
|
70
|
+
telemetry_store: TelemetryStore | None = None,
|
|
71
|
+
trigger_manager: TriggerManager | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._vin = vin
|
|
74
|
+
self._app_ctx = app_ctx
|
|
75
|
+
self._store = telemetry_store
|
|
76
|
+
self._trigger_manager = trigger_manager
|
|
77
|
+
self._vehicle_data_cache: dict[str, Any] | None = None
|
|
78
|
+
self._fetch_task: asyncio.Task[None] | None = None
|
|
79
|
+
self._handlers: dict[str, Any] = {
|
|
80
|
+
# Reads
|
|
81
|
+
"location.get": self._handle_location_get,
|
|
82
|
+
"battery.get": self._handle_battery_get,
|
|
83
|
+
"temperature.get": self._handle_temperature_get,
|
|
84
|
+
"speed.get": self._handle_speed_get,
|
|
85
|
+
"charge_state.get": self._handle_charge_state_get,
|
|
86
|
+
"security.get": self._handle_security_get,
|
|
87
|
+
# Writes
|
|
88
|
+
"door.lock": self._handle_door_lock,
|
|
89
|
+
"door.unlock": self._handle_door_unlock,
|
|
90
|
+
"climate.on": self._handle_climate_on,
|
|
91
|
+
"climate.off": self._handle_climate_off,
|
|
92
|
+
"climate.set_temp": self._handle_climate_set_temp,
|
|
93
|
+
"charge.start": self._handle_charge_start,
|
|
94
|
+
"charge.stop": self._handle_charge_stop,
|
|
95
|
+
"charge.set_limit": self._handle_charge_set_limit,
|
|
96
|
+
"trunk.open": self._handle_trunk_open,
|
|
97
|
+
"frunk.open": self._handle_frunk_open,
|
|
98
|
+
"flash_lights": self._handle_flash_lights,
|
|
99
|
+
"honk_horn": self._handle_honk_horn,
|
|
100
|
+
"sentry.on": self._handle_sentry_on,
|
|
101
|
+
"sentry.off": self._handle_sentry_off,
|
|
102
|
+
"nav.send": self._handle_nav_send,
|
|
103
|
+
"nav.gps": self._handle_nav_gps,
|
|
104
|
+
"nav.supercharger": self._handle_nav_supercharger,
|
|
105
|
+
"nav.waypoints": self._handle_nav_waypoints,
|
|
106
|
+
"homelink.trigger": self._handle_homelink,
|
|
107
|
+
"system.run": self._handle_system_run,
|
|
108
|
+
# Trigger commands
|
|
109
|
+
"trigger.create": self._handle_trigger_create,
|
|
110
|
+
"trigger.delete": self._handle_trigger_delete,
|
|
111
|
+
"trigger.list": self._handle_trigger_list,
|
|
112
|
+
"trigger.poll": self._handle_trigger_poll,
|
|
113
|
+
# Convenience trigger aliases
|
|
114
|
+
"cabin_temp.trigger": self._handle_cabin_temp_trigger,
|
|
115
|
+
"outside_temp.trigger": self._handle_outside_temp_trigger,
|
|
116
|
+
"battery.trigger": self._handle_battery_trigger,
|
|
117
|
+
"location.trigger": self._handle_location_trigger,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async def dispatch(self, msg: dict[str, Any]) -> dict[str, Any] | None:
|
|
121
|
+
"""Dispatch an inbound request to the appropriate handler.
|
|
122
|
+
|
|
123
|
+
Returns ``None`` for unknown methods (gateway sends error
|
|
124
|
+
response). Raises on handler errors (gateway catches and
|
|
125
|
+
returns error response).
|
|
126
|
+
"""
|
|
127
|
+
method = msg.get("method", "")
|
|
128
|
+
logger.debug("Dispatch: method=%s id=%s", method, msg.get("id", "?"))
|
|
129
|
+
handler = self._handlers.get(method)
|
|
130
|
+
if handler is None:
|
|
131
|
+
logger.warning("No handler for method: %s", method)
|
|
132
|
+
return None
|
|
133
|
+
params = msg.get("params", {})
|
|
134
|
+
result: dict[str, Any] | None = await handler(params)
|
|
135
|
+
logger.debug("Dispatch result for %s: %s", method, result)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
# -- Read helpers --------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _store_get(self, field_name: str) -> Any | None:
|
|
141
|
+
"""Return the latest value from the telemetry store, or None."""
|
|
142
|
+
if self._store is None:
|
|
143
|
+
return None
|
|
144
|
+
snap = self._store.get(field_name)
|
|
145
|
+
return snap.value if snap is not None else None
|
|
146
|
+
|
|
147
|
+
async def _get_vehicle_data(self) -> dict[str, Any]:
|
|
148
|
+
"""Fetch full vehicle data via the API (with auto-wake retry).
|
|
149
|
+
|
|
150
|
+
Caches the result so subsequent read handlers within the same
|
|
151
|
+
request batch don't trigger duplicate API calls.
|
|
152
|
+
"""
|
|
153
|
+
if self._vehicle_data_cache is not None:
|
|
154
|
+
return self._vehicle_data_cache
|
|
155
|
+
logger.debug("Fetching vehicle data from Fleet API for %s", self._vin)
|
|
156
|
+
client, vehicle_api = get_vehicle_api(self._app_ctx)
|
|
157
|
+
try:
|
|
158
|
+
vdata = await self._auto_wake(lambda: vehicle_api.get_vehicle_data(self._vin))
|
|
159
|
+
data: dict[str, Any] = vdata.model_dump()
|
|
160
|
+
self._vehicle_data_cache = data
|
|
161
|
+
return data
|
|
162
|
+
finally:
|
|
163
|
+
await client.close()
|
|
164
|
+
|
|
165
|
+
def _get_vehicle_data_or_none(self) -> dict[str, Any] | None:
|
|
166
|
+
"""Return cached vehicle data if available, else ``None``."""
|
|
167
|
+
return self._vehicle_data_cache
|
|
168
|
+
|
|
169
|
+
def _schedule_vehicle_data_fetch(self) -> None:
|
|
170
|
+
"""Kick off a background fetch if one isn't already running."""
|
|
171
|
+
if self._fetch_task is not None and not self._fetch_task.done():
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
async def _bg_fetch() -> None:
|
|
175
|
+
try:
|
|
176
|
+
await self._get_vehicle_data()
|
|
177
|
+
logger.info("Background vehicle data fetch complete")
|
|
178
|
+
except Exception:
|
|
179
|
+
logger.warning("Background vehicle data fetch failed", exc_info=True)
|
|
180
|
+
|
|
181
|
+
self._fetch_task = asyncio.create_task(_bg_fetch())
|
|
182
|
+
|
|
183
|
+
# -- Read handlers -------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _read_from_api_cache(self, extractor: str) -> dict[str, Any] | None:
|
|
186
|
+
"""Try to answer from the cached vehicle data.
|
|
187
|
+
|
|
188
|
+
*extractor* is a dot path like ``"drive_state"`` or
|
|
189
|
+
``"charge_state"``. Returns the sub-dict or ``None`` if no
|
|
190
|
+
cached data is available. Kicks off a background fetch if the
|
|
191
|
+
cache is empty.
|
|
192
|
+
"""
|
|
193
|
+
vdata = self._get_vehicle_data_or_none()
|
|
194
|
+
if vdata is None:
|
|
195
|
+
self._schedule_vehicle_data_fetch()
|
|
196
|
+
return None
|
|
197
|
+
return vdata.get(extractor) or {}
|
|
198
|
+
|
|
199
|
+
async def _handle_location_get(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
200
|
+
loc = self._store_get("Location")
|
|
201
|
+
if loc is not None and isinstance(loc, dict):
|
|
202
|
+
return {
|
|
203
|
+
"latitude": loc.get("latitude"),
|
|
204
|
+
"longitude": loc.get("longitude"),
|
|
205
|
+
"heading": loc.get("heading"),
|
|
206
|
+
"speed": loc.get("speed"),
|
|
207
|
+
}
|
|
208
|
+
drive = self._read_from_api_cache("drive_state")
|
|
209
|
+
if drive is None:
|
|
210
|
+
return {"pending": True}
|
|
211
|
+
return {
|
|
212
|
+
"latitude": drive.get("latitude"),
|
|
213
|
+
"longitude": drive.get("longitude"),
|
|
214
|
+
"heading": drive.get("heading"),
|
|
215
|
+
"speed": drive.get("speed"),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async def _handle_battery_get(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
219
|
+
soc = self._store_get("Soc") or self._store_get("BatteryLevel")
|
|
220
|
+
range_mi = self._store_get("EstBatteryRange")
|
|
221
|
+
if soc is not None:
|
|
222
|
+
result: dict[str, Any] = {"battery_level": soc}
|
|
223
|
+
if range_mi is not None:
|
|
224
|
+
result["range_miles"] = range_mi
|
|
225
|
+
return result
|
|
226
|
+
cs = self._read_from_api_cache("charge_state")
|
|
227
|
+
if cs is None:
|
|
228
|
+
return {"pending": True}
|
|
229
|
+
return {
|
|
230
|
+
"battery_level": cs.get("battery_level"),
|
|
231
|
+
"range_miles": cs.get("battery_range"),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async def _handle_temperature_get(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
235
|
+
inside = self._store_get("InsideTemp")
|
|
236
|
+
outside = self._store_get("OutsideTemp")
|
|
237
|
+
if inside is not None or outside is not None:
|
|
238
|
+
result: dict[str, Any] = {}
|
|
239
|
+
if inside is not None:
|
|
240
|
+
result["inside_temp_c"] = inside
|
|
241
|
+
if outside is not None:
|
|
242
|
+
result["outside_temp_c"] = outside
|
|
243
|
+
return result
|
|
244
|
+
climate = self._read_from_api_cache("climate_state")
|
|
245
|
+
if climate is None:
|
|
246
|
+
return {"pending": True}
|
|
247
|
+
return {
|
|
248
|
+
"inside_temp_c": climate.get("inside_temp"),
|
|
249
|
+
"outside_temp_c": climate.get("outside_temp"),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async def _handle_speed_get(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
253
|
+
speed = self._store_get("VehicleSpeed")
|
|
254
|
+
if speed is not None:
|
|
255
|
+
return {"speed_mph": speed}
|
|
256
|
+
drive = self._read_from_api_cache("drive_state")
|
|
257
|
+
if drive is None:
|
|
258
|
+
return {"pending": True}
|
|
259
|
+
return {"speed_mph": drive.get("speed")}
|
|
260
|
+
|
|
261
|
+
async def _handle_charge_state_get(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
262
|
+
state = self._store_get("ChargeState") or self._store_get("DetailedChargeState")
|
|
263
|
+
if state is not None:
|
|
264
|
+
return {"charge_state": state}
|
|
265
|
+
cs = self._read_from_api_cache("charge_state")
|
|
266
|
+
if cs is None:
|
|
267
|
+
return {"pending": True}
|
|
268
|
+
return {"charge_state": cs.get("charging_state")}
|
|
269
|
+
|
|
270
|
+
async def _handle_security_get(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
271
|
+
locked = self._store_get("Locked")
|
|
272
|
+
sentry = self._store_get("SentryMode")
|
|
273
|
+
if locked is not None or sentry is not None:
|
|
274
|
+
result: dict[str, Any] = {}
|
|
275
|
+
if locked is not None:
|
|
276
|
+
result["locked"] = locked
|
|
277
|
+
if sentry is not None:
|
|
278
|
+
result["sentry_mode"] = sentry
|
|
279
|
+
return result
|
|
280
|
+
vs = self._read_from_api_cache("vehicle_state")
|
|
281
|
+
if vs is None:
|
|
282
|
+
return {"pending": True}
|
|
283
|
+
return {
|
|
284
|
+
"locked": vs.get("locked"),
|
|
285
|
+
"sentry_mode": vs.get("sentry_mode"),
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
# -- Write helpers -------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
async def _auto_wake(self, operation: Any) -> Any:
|
|
291
|
+
"""Retry *operation* once after waking on VehicleAsleepError."""
|
|
292
|
+
try:
|
|
293
|
+
return await operation()
|
|
294
|
+
except VehicleAsleepError:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
logger.info("Vehicle asleep — sending wake for %s", self._vin)
|
|
298
|
+
client, vehicle_api = get_vehicle_api(self._app_ctx)
|
|
299
|
+
try:
|
|
300
|
+
await vehicle_api.wake(self._vin)
|
|
301
|
+
finally:
|
|
302
|
+
await client.close()
|
|
303
|
+
|
|
304
|
+
return await operation()
|
|
305
|
+
|
|
306
|
+
async def _execute_command(self, method_name: str, body: dict[str, Any] | None = None) -> str:
|
|
307
|
+
"""Execute a vehicle command and return the reason string."""
|
|
308
|
+
client, _vehicle_api, cmd_api = get_command_api(self._app_ctx)
|
|
309
|
+
check_command_guards(cmd_api, method_name)
|
|
310
|
+
try:
|
|
311
|
+
method = getattr(cmd_api, method_name)
|
|
312
|
+
|
|
313
|
+
async def _call() -> Any:
|
|
314
|
+
return await method(self._vin, **body) if body else await method(self._vin)
|
|
315
|
+
|
|
316
|
+
result = await self._auto_wake(_call)
|
|
317
|
+
finally:
|
|
318
|
+
await client.close()
|
|
319
|
+
|
|
320
|
+
invalidate_cache_for_vin(self._app_ctx, self._vin)
|
|
321
|
+
return result.response.reason or "ok"
|
|
322
|
+
|
|
323
|
+
# -- Write handlers ------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
async def _simple_command(
|
|
326
|
+
self, method_name: str, body: dict[str, Any] | None = None
|
|
327
|
+
) -> dict[str, Any]:
|
|
328
|
+
"""Execute a command and return ``{result: True, reason: ...}``."""
|
|
329
|
+
reason = await self._execute_command(method_name, body)
|
|
330
|
+
return {"result": True, "reason": reason}
|
|
331
|
+
|
|
332
|
+
async def _handle_door_lock(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
333
|
+
return await self._simple_command("door_lock")
|
|
334
|
+
|
|
335
|
+
async def _handle_door_unlock(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
336
|
+
return await self._simple_command("door_unlock")
|
|
337
|
+
|
|
338
|
+
async def _handle_climate_on(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
339
|
+
return await self._simple_command("auto_conditioning_start")
|
|
340
|
+
|
|
341
|
+
async def _handle_climate_off(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
342
|
+
return await self._simple_command("auto_conditioning_stop")
|
|
343
|
+
|
|
344
|
+
async def _handle_climate_set_temp(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
345
|
+
temp = params.get("temp")
|
|
346
|
+
if temp is None:
|
|
347
|
+
raise ValueError("climate.set_temp requires 'temp' parameter")
|
|
348
|
+
temp_f = float(temp)
|
|
349
|
+
return await self._simple_command(
|
|
350
|
+
"set_temps", {"driver_temp": temp_f, "passenger_temp": temp_f}
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
async def _handle_charge_start(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
354
|
+
return await self._simple_command("charge_start")
|
|
355
|
+
|
|
356
|
+
async def _handle_charge_stop(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
357
|
+
return await self._simple_command("charge_stop")
|
|
358
|
+
|
|
359
|
+
async def _handle_charge_set_limit(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
360
|
+
percent = params.get("percent")
|
|
361
|
+
if percent is None:
|
|
362
|
+
raise ValueError("charge.set_limit requires 'percent' parameter")
|
|
363
|
+
return await self._simple_command("set_charge_limit", {"percent": int(percent)})
|
|
364
|
+
|
|
365
|
+
async def _handle_trunk_open(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
366
|
+
return await self._simple_command("actuate_trunk", {"which_trunk": "rear"})
|
|
367
|
+
|
|
368
|
+
async def _handle_frunk_open(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
369
|
+
return await self._simple_command("actuate_trunk", {"which_trunk": "front"})
|
|
370
|
+
|
|
371
|
+
async def _handle_flash_lights(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
372
|
+
return await self._simple_command("flash_lights")
|
|
373
|
+
|
|
374
|
+
async def _handle_honk_horn(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
375
|
+
return await self._simple_command("honk_horn")
|
|
376
|
+
|
|
377
|
+
async def _handle_sentry_on(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
378
|
+
return await self._simple_command("set_sentry_mode", {"on": True})
|
|
379
|
+
|
|
380
|
+
async def _handle_sentry_off(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
381
|
+
return await self._simple_command("set_sentry_mode", {"on": False})
|
|
382
|
+
|
|
383
|
+
# -- Navigation handlers -------------------------------------------------
|
|
384
|
+
|
|
385
|
+
async def _handle_nav_send(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
386
|
+
address = params.get("address")
|
|
387
|
+
if not address:
|
|
388
|
+
raise ValueError("nav.send requires 'address' parameter")
|
|
389
|
+
return await self._simple_command("share", {"address": address})
|
|
390
|
+
|
|
391
|
+
async def _handle_nav_gps(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
392
|
+
lat = params.get("lat")
|
|
393
|
+
lon = params.get("lon")
|
|
394
|
+
if lat is None or lon is None:
|
|
395
|
+
raise ValueError("nav.gps requires 'lat' and 'lon' parameters")
|
|
396
|
+
body: dict[str, Any] = {"lat": float(lat), "lon": float(lon)}
|
|
397
|
+
order = params.get("order")
|
|
398
|
+
if order is not None:
|
|
399
|
+
body["order"] = int(order)
|
|
400
|
+
return await self._simple_command("navigation_gps_request", body)
|
|
401
|
+
|
|
402
|
+
async def _handle_nav_supercharger(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
403
|
+
return await self._simple_command("navigation_sc_request")
|
|
404
|
+
|
|
405
|
+
async def _handle_nav_waypoints(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
406
|
+
waypoints = params.get("waypoints")
|
|
407
|
+
if not waypoints:
|
|
408
|
+
raise ValueError("nav.waypoints requires 'waypoints' parameter")
|
|
409
|
+
return await self._simple_command("navigation_waypoints_request", {"waypoints": waypoints})
|
|
410
|
+
|
|
411
|
+
async def _handle_homelink(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
412
|
+
lat = params.get("lat")
|
|
413
|
+
lon = params.get("lon")
|
|
414
|
+
if lat is None or lon is None:
|
|
415
|
+
raise ValueError("homelink.trigger requires 'lat' and 'lon' parameters")
|
|
416
|
+
return await self._simple_command(
|
|
417
|
+
"trigger_homelink", {"lat": float(lat), "lon": float(lon)}
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# -- Meta-dispatch handler -----------------------------------------------
|
|
421
|
+
|
|
422
|
+
async def _handle_system_run(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
423
|
+
"""Invoke any registered handler by name.
|
|
424
|
+
|
|
425
|
+
Accepts both OpenClaw-style (``door.lock``) and API-style
|
|
426
|
+
(``door_lock``) method names via :data:`_METHOD_ALIASES`.
|
|
427
|
+
"""
|
|
428
|
+
method = params.get("method", "")
|
|
429
|
+
if not method:
|
|
430
|
+
raise ValueError("system.run requires 'method' parameter")
|
|
431
|
+
resolved = _METHOD_ALIASES.get(method, method)
|
|
432
|
+
if resolved == "system.run":
|
|
433
|
+
raise ValueError("system.run cannot invoke itself")
|
|
434
|
+
inner_params = params.get("params", {})
|
|
435
|
+
result = await self.dispatch({"method": resolved, "params": inner_params})
|
|
436
|
+
if result is None:
|
|
437
|
+
raise ValueError(f"Unknown method: {method}")
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
# -- Trigger handlers ----------------------------------------------------
|
|
441
|
+
|
|
442
|
+
def _require_trigger_manager(self) -> TriggerManager:
|
|
443
|
+
"""Return the trigger manager or raise if unavailable."""
|
|
444
|
+
if self._trigger_manager is None:
|
|
445
|
+
raise RuntimeError("Triggers not available")
|
|
446
|
+
return self._trigger_manager
|
|
447
|
+
|
|
448
|
+
async def _handle_trigger_create(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
449
|
+
"""Create a new trigger from the given condition parameters."""
|
|
450
|
+
mgr = self._require_trigger_manager()
|
|
451
|
+
field = params.get("field", "")
|
|
452
|
+
if not field:
|
|
453
|
+
raise ValueError("trigger.create requires 'field' parameter")
|
|
454
|
+
|
|
455
|
+
op_str = params.get("operator", "")
|
|
456
|
+
if not op_str:
|
|
457
|
+
raise ValueError("trigger.create requires 'operator' parameter")
|
|
458
|
+
|
|
459
|
+
operator = TriggerOperator(op_str)
|
|
460
|
+
condition = TriggerCondition(
|
|
461
|
+
field=field,
|
|
462
|
+
operator=operator,
|
|
463
|
+
value=params.get("value"),
|
|
464
|
+
)
|
|
465
|
+
trigger = TriggerDefinition(
|
|
466
|
+
condition=condition,
|
|
467
|
+
once=params.get("once", False),
|
|
468
|
+
cooldown_seconds=params.get("cooldown_seconds", 60.0),
|
|
469
|
+
)
|
|
470
|
+
created = mgr.create(trigger)
|
|
471
|
+
return {
|
|
472
|
+
"id": created.id,
|
|
473
|
+
"field": created.condition.field,
|
|
474
|
+
"operator": created.condition.operator.value,
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async def _handle_trigger_delete(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
478
|
+
"""Delete a trigger by ID."""
|
|
479
|
+
mgr = self._require_trigger_manager()
|
|
480
|
+
trigger_id = params.get("id", "")
|
|
481
|
+
if not trigger_id:
|
|
482
|
+
raise ValueError("trigger.delete requires 'id' parameter")
|
|
483
|
+
deleted = mgr.delete(trigger_id)
|
|
484
|
+
return {"deleted": deleted, "id": trigger_id}
|
|
485
|
+
|
|
486
|
+
async def _handle_trigger_list(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
487
|
+
"""List all registered triggers."""
|
|
488
|
+
mgr = self._require_trigger_manager()
|
|
489
|
+
triggers = mgr.list_all()
|
|
490
|
+
return {
|
|
491
|
+
"triggers": [
|
|
492
|
+
{
|
|
493
|
+
"id": t.id,
|
|
494
|
+
"field": t.condition.field,
|
|
495
|
+
"operator": t.condition.operator.value,
|
|
496
|
+
"value": t.condition.value,
|
|
497
|
+
"once": t.once,
|
|
498
|
+
"cooldown_seconds": t.cooldown_seconds,
|
|
499
|
+
}
|
|
500
|
+
for t in triggers
|
|
501
|
+
]
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async def _handle_trigger_poll(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
505
|
+
"""Drain and return pending trigger notifications."""
|
|
506
|
+
mgr = self._require_trigger_manager()
|
|
507
|
+
notifications = mgr.drain_pending()
|
|
508
|
+
return {"notifications": [n.model_dump(mode="json") for n in notifications]}
|
|
509
|
+
|
|
510
|
+
# -- Convenience trigger aliases -----------------------------------------
|
|
511
|
+
|
|
512
|
+
async def _handle_cabin_temp_trigger(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
513
|
+
return await self._handle_trigger_create({**params, "field": "InsideTemp"})
|
|
514
|
+
|
|
515
|
+
async def _handle_outside_temp_trigger(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
516
|
+
return await self._handle_trigger_create({**params, "field": "OutsideTemp"})
|
|
517
|
+
|
|
518
|
+
async def _handle_battery_trigger(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
519
|
+
return await self._handle_trigger_create({**params, "field": "BatteryLevel"})
|
|
520
|
+
|
|
521
|
+
async def _handle_location_trigger(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
522
|
+
return await self._handle_trigger_create({**params, "field": "Location"})
|