Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] - 2026-02-03

### Changed

- **Slim node capabilities** — node now advertises only `location.get` + `system.run` to the gateway (was 29 individual commands); all 34 handlers remain available via `system.run` meta-dispatch, reducing handshake payload and simplifying capability negotiation
- **Explicit `send_connected()` lifecycle** — `node.connected` event is now sent explicitly after `connect_with_backoff()` and after successful reconnection, replacing the implicit first-frame trigger; CLI callers show a warning if the lifecycle event fails while the connection itself succeeded
- **`send_connected()` returns bool** — callers can now detect lifecycle event failure; both `openclaw bridge` and `serve` commands display a yellow warning when the event fails to send
- **Separated reconnect error handling** — `_maybe_reconnect()` now handles connection failure and lifecycle event failure independently; a failed `node.connected` no longer incorrectly doubles the reconnect backoff timer

### Added

- **`on_reconnect` gateway callback** — `GatewayClient` accepts an `on_reconnect` callback invoked after the receive loop successfully reconnects, ensuring `node.connected` is sent on every reconnection (not just the initial connect)
- **`system.run` activity logging** — dispatcher logs the resolved inner method when routing through `system.run`, so operational logs show `system.run → door.lock` instead of just `system.run`

### Fixed

- **`send_connected()` false-positive logging** — no longer logs "Sent node.connected event" when the gateway is disconnected (the event was being silently dropped by `send_event()`)
- **`OpenClawPipeline.dispatcher` typing** — changed from `Any` to `CommandDispatcher` for static analysis support

## [0.4.0] - 2026-02-02

### Added
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "tescmd"
version = "0.4.0"
version = "0.5.0"
description = "A Python CLI for querying and controlling Tesla vehicles via the Fleet API"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/tescmd/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""tescmd — A Python CLI for querying and controlling Tesla vehicles via the Fleet API."""

__version__ = "0.4.0"
__version__ = "0.5.0"
3 changes: 3 additions & 0 deletions src/tescmd/cli/openclaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,11 @@ async def _cmd_bridge(
if formatter.format != "json":
formatter.rich.info(f"Connecting to OpenClaw Gateway: {config.gateway_url}")
await gw.connect_with_backoff(max_attempts=5)
lifecycle_ok = await bridge.send_connected()
if formatter.format != "json":
formatter.rich.info("[green]Connected to gateway.[/green]")
if not lifecycle_ok:
formatter.rich.info("[yellow]Warning: node.connected event failed[/yellow]")
else:
if formatter.format != "json":
formatter.rich.info("[yellow]Dry-run mode — events will be logged as JSONL.[/yellow]")
Expand Down
5 changes: 5 additions & 0 deletions src/tescmd/cli/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,13 @@ async def _jsonl_sink(frame: object) -> None:
if is_rich:
formatter.rich.info(f"Connecting to OpenClaw Gateway: {config.gateway_url}")
await gw.connect_with_backoff(max_attempts=5)
lifecycle_ok = await oc_bridge.send_connected()
if is_rich:
formatter.rich.info("[green]Connected to OpenClaw gateway.[/green]")
if not lifecycle_ok:
formatter.rich.info(
"[yellow]Warning: node.connected event failed[/yellow]"
)
else:
if is_rich:
formatter.rich.info(
Expand Down
60 changes: 45 additions & 15 deletions src/tescmd/openclaw/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
if TYPE_CHECKING:
from tescmd.cli.main import AppContext
from tescmd.openclaw.config import BridgeConfig
from tescmd.openclaw.dispatcher import CommandDispatcher
from tescmd.openclaw.emitter import EventEmitter
from tescmd.openclaw.filters import DualGateFilter
from tescmd.openclaw.gateway import GatewayClient
Expand Down Expand Up @@ -61,7 +62,6 @@ def __init__(
self._event_count = 0
self._drop_count = 0
self._last_event_time: float | None = None
self._first_frame_received = False
self._reconnect_at: float = 0.0
self._reconnect_backoff: float = _RECONNECT_BASE
self._shutting_down = False
Expand All @@ -88,15 +88,20 @@ async def _maybe_reconnect(self) -> None:
logger.info("Attempting OpenClaw gateway reconnection...")
try:
await self._gateway.connect()
self._reconnect_backoff = _RECONNECT_BASE
logger.info("Reconnected to OpenClaw gateway")
except Exception:
self._reconnect_at = now + self._reconnect_backoff
logger.warning(
"Reconnection failed — next attempt in %.0fs",
self._reconnect_backoff,
)
self._reconnect_backoff = min(self._reconnect_backoff * 2, _RECONNECT_MAX)
return
self._reconnect_backoff = _RECONNECT_BASE
logger.info("Reconnected to OpenClaw gateway")
try:
await self.send_connected()
except Exception:
logger.warning("Failed to send connected event after reconnect", exc_info=True)

def _build_lifecycle_event(self, event_type: str) -> dict[str, Any]:
"""Build a ``req:agent`` lifecycle event (connecting/disconnecting)."""
Expand Down Expand Up @@ -148,6 +153,30 @@ async def _push_trigger_notification(n: Any) -> None:

return _push_trigger_notification

async def send_connected(self) -> bool:
"""Send a ``node.connected`` lifecycle event to the gateway.

Returns ``True`` if the event was sent (or skipped due to dry-run),
``False`` if the gateway was disconnected or the send failed.
"""
if self._dry_run:
return True
if not self._gateway.is_connected:
logger.warning("Cannot send node.connected — gateway not connected")
return False
event = self._build_lifecycle_event("node.connected")
try:
await self._gateway.send_event(event)
except Exception:
logger.warning("Failed to send connected event", exc_info=True)
return False
# send_event() swallows errors and marks disconnected, so check again.
if not self._gateway.is_connected:
logger.warning("Failed to send connected event — gateway disconnected during send")
return False
logger.info("Sent node.connected event")
return True

async def send_disconnecting(self) -> None:
"""Send a ``node.disconnecting`` lifecycle event to the gateway.

Expand Down Expand Up @@ -175,17 +204,6 @@ async def on_frame(self, frame: TelemetryFrame) -> None:
"""
now = time.monotonic()

# Send node.connected lifecycle event on the very first frame.
if not self._first_frame_received:
self._first_frame_received = True
if not self._dry_run and self._gateway.is_connected:
lifecycle_event = self._build_lifecycle_event("node.connected")
try:
await self._gateway.send_event(lifecycle_event)
logger.info("Sent node.connected event")
except Exception:
logger.warning("Failed to send connected event", exc_info=True)

for datum in frame.data:
if not self._filter.should_emit(datum.field_name, datum.value, now):
self._drop_count += 1
Expand Down Expand Up @@ -259,7 +277,7 @@ class OpenClawPipeline:
gateway: GatewayClient
bridge: TelemetryBridge
telemetry_store: TelemetryStore
dispatcher: Any # CommandDispatcher — avoids circular import
dispatcher: CommandDispatcher


def build_openclaw_pipeline(
Expand Down Expand Up @@ -301,6 +319,17 @@ def build_openclaw_pipeline(

from tescmd import __version__

# bridge is assigned below, but the closure captures it by reference —
# on_reconnect is only called during live reconnection, long after this
# function returns, so bridge is always initialised by then.
bridge: TelemetryBridge | None = None

async def _on_reconnect() -> None:
if bridge is not None:
await bridge.send_connected()
else:
logger.error("on_reconnect fired but bridge is None — this should never happen")

gateway = GatewayClient(
config.gateway_url,
token=config.gateway_token,
Expand All @@ -310,6 +339,7 @@ def build_openclaw_pipeline(
model_identifier=vin,
capabilities=config.capabilities,
on_request=dispatcher.dispatch,
on_reconnect=_on_reconnect,
)
bridge = TelemetryBridge(
gateway,
Expand Down
41 changes: 9 additions & 32 deletions src/tescmd/openclaw/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@
class NodeCapabilities(BaseModel):
"""Advertised capabilities for the OpenClaw node role.

The node advertises only two commands to the gateway:

- ``location.get`` (read) — standard node location capability
- ``system.run`` (write) — single entry point; the gateway routes all
invocations through this method and the internal
:class:`~tescmd.openclaw.dispatcher.CommandDispatcher` fans out to
the full set of 34 handlers.

Maps to the gateway connect schema fields:
- ``caps``: broad capability categories (e.g. ``"location"``, ``"climate"``)
- ``caps``: broad capability categories (``"location"``, ``"system"``)
- ``commands``: specific method names the node can handle
- ``permissions``: per-command permission booleans

Expand All @@ -23,39 +31,8 @@ class NodeCapabilities(BaseModel):

reads: list[str] = [
"location.get",
"battery.get",
"temperature.get",
"speed.get",
"charge_state.get",
"security.get",
# Trigger reads
"trigger.list",
"trigger.poll",
]
writes: list[str] = [
"door.lock",
"door.unlock",
"climate.on",
"climate.off",
"climate.set_temp",
"charge.start",
"charge.stop",
"charge.set_limit",
"trunk.open",
"frunk.open",
"flash_lights",
"honk_horn",
"sentry.on",
"sentry.off",
# Trigger writes
"trigger.create",
"trigger.delete",
# Convenience trigger aliases
"cabin_temp.trigger",
"outside_temp.trigger",
"battery.trigger",
"location.trigger",
# Meta-dispatch
"system.run",
]

Expand Down
1 change: 1 addition & 0 deletions src/tescmd/openclaw/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ async def _handle_system_run(self, params: dict[str, Any]) -> dict[str, Any]:
resolved = _METHOD_ALIASES.get(method, method)
if resolved == "system.run":
raise ValueError("system.run cannot invoke itself")
logger.info("system.run → %s", resolved)
inner_params = params.get("params", {})
result = await self.dispatch({"method": resolved, "params": inner_params})
if result is None:
Expand Down
21 changes: 20 additions & 1 deletion src/tescmd/openclaw/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ def __init__(
model_identifier: str | None = None,
capabilities: NodeCapabilities | None = None,
on_request: Callable[[dict[str, Any]], Awaitable[dict[str, Any] | None]] | None = None,
on_reconnect: Callable[[], Awaitable[None]] | None = None,
) -> None:
self._url = url
self._token = token
Expand All @@ -243,6 +244,7 @@ def __init__(
self._model_identifier = model_identifier or "tescmd"
self._capabilities = capabilities
self._on_request = on_request
self._on_reconnect = on_reconnect
self._ws: ClientConnection | None = None
self._connected = False
self._send_count = 0
Expand Down Expand Up @@ -523,6 +525,12 @@ async def _receive_loop(self) -> None:
logger.error("Reconnection failed — receive loop exiting")
break

if self._on_reconnect is not None:
try:
await self._on_reconnect()
except Exception:
logger.warning("on_reconnect callback failed", exc_info=True)

async def _try_reconnect(self) -> bool:
"""Attempt to re-establish the gateway connection with exponential backoff.

Expand All @@ -544,7 +552,6 @@ async def _handle_invoke(self, payload: dict[str, Any]) -> None:
invoke_id = payload.get("id", "")
command = payload.get("command", "")
params_json = payload.get("paramsJSON", "{}")
logger.info("Invoke request: id=%s command=%s", invoke_id, command)

if not self._on_request:
await self._send_invoke_result(invoke_id, ok=False, error="no handler configured")
Expand All @@ -563,6 +570,18 @@ async def _handle_invoke(self, payload: dict[str, Any]) -> None:
)
params = {}

# Log with the real command name — for system.run, peek at the
# inner method so the activity log shows what's actually invoked.
if command == "system.run":
inner = params.get("method", "") or params.get("command", "")
if isinstance(inner, list):
inner = inner[0] if inner else ""
logger.info(
"Invoke request: id=%s command=%s (via system.run)", invoke_id, inner or "?"
)
else:
logger.info("Invoke request: id=%s command=%s", invoke_id, command)

# Build the message dict the dispatcher expects
dispatch_msg: dict[str, Any] = {
"method": command,
Expand Down
Loading