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,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"})