From b4253ecd22ec39a74297c404c1df45b4bb9e50aa Mon Sep 17 00:00:00 2001 From: Sean McLellan Date: Tue, 3 Feb 2026 23:50:04 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20v0.5.0=20=E2=80=94=20OpenClaw=20lifecyc?= =?UTF-8?q?le=20events,=20slim=20capabilities,=20reconnect=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Slim node capabilities from 29 to 2 (location.get + system.run); all 34 handlers remain available via system.run meta-dispatch - Explicit send_connected() lifecycle replaces implicit first-frame trigger; returns bool so CLI callers can warn on failure - on_reconnect gateway callback ensures node.connected sent on every reconnect, not just initial connect - Separated reconnect error handling: connect failure and lifecycle event failure are now independent (fixes incorrect backoff doubling) - send_connected() guards on is_connected before sending (fixes false-positive logging when gateway disconnected) - system.run activity logging shows resolved inner method - OpenClawPipeline.dispatcher typed as CommandDispatcher (was Any) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 19 ++++++ pyproject.toml | 2 +- src/tescmd/__init__.py | 2 +- src/tescmd/cli/openclaw.py | 3 + src/tescmd/cli/serve.py | 5 ++ src/tescmd/openclaw/bridge.py | 60 +++++++++++++----- src/tescmd/openclaw/config.py | 41 +++--------- src/tescmd/openclaw/dispatcher.py | 1 + src/tescmd/openclaw/gateway.py | 21 ++++++- tests/openclaw/test_bridge.py | 101 +++++++++++++++++++----------- tests/openclaw/test_config.py | 20 ++++-- tests/openclaw/test_gateway.py | 86 ++++++++++++++++++------- 12 files changed, 247 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 772bfc1..0e14b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 1ee16d0..1665302 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/tescmd/__init__.py b/src/tescmd/__init__.py index 8f02c5a..8dbfbae 100644 --- a/src/tescmd/__init__.py +++ b/src/tescmd/__init__.py @@ -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" diff --git a/src/tescmd/cli/openclaw.py b/src/tescmd/cli/openclaw.py index a0f35d0..fcb3b8c 100644 --- a/src/tescmd/cli/openclaw.py +++ b/src/tescmd/cli/openclaw.py @@ -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]") diff --git a/src/tescmd/cli/serve.py b/src/tescmd/cli/serve.py index e8df89a..98eefe7 100644 --- a/src/tescmd/cli/serve.py +++ b/src/tescmd/cli/serve.py @@ -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( diff --git a/src/tescmd/openclaw/bridge.py b/src/tescmd/openclaw/bridge.py index 2774b12..a315099 100644 --- a/src/tescmd/openclaw/bridge.py +++ b/src/tescmd/openclaw/bridge.py @@ -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 @@ -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 @@ -88,8 +88,6 @@ 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( @@ -97,6 +95,13 @@ async def _maybe_reconnect(self) -> None: 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).""" @@ -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. @@ -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 @@ -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( @@ -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, @@ -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, diff --git a/src/tescmd/openclaw/config.py b/src/tescmd/openclaw/config.py index ba14dbb..adb0e9e 100644 --- a/src/tescmd/openclaw/config.py +++ b/src/tescmd/openclaw/config.py @@ -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 @@ -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", ] diff --git a/src/tescmd/openclaw/dispatcher.py b/src/tescmd/openclaw/dispatcher.py index c828cd9..b962f76 100644 --- a/src/tescmd/openclaw/dispatcher.py +++ b/src/tescmd/openclaw/dispatcher.py @@ -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: diff --git a/src/tescmd/openclaw/gateway.py b/src/tescmd/openclaw/gateway.py index 74d6bc6..7b3e730 100644 --- a/src/tescmd/openclaw/gateway.py +++ b/src/tescmd/openclaw/gateway.py @@ -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 @@ -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 @@ -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. @@ -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") @@ -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, diff --git a/tests/openclaw/test_bridge.py b/tests/openclaw/test_bridge.py index 15b8d90..9ec6997 100644 --- a/tests/openclaw/test_bridge.py +++ b/tests/openclaw/test_bridge.py @@ -61,10 +61,8 @@ async def test_emit_mapped_datum( assert bridge.event_count == 1 assert bridge.drop_count == 0 - # 1 connected lifecycle event + 1 data event = 2 sends - assert gateway._ws.send.call_count == 2 + assert gateway._ws.send.call_count == 1 - # Last send should be the data event sent = json.loads(gateway._ws.send.call_args[0][0]) assert sent["method"] == "req:agent" assert sent["params"]["event_type"] == "battery" @@ -91,8 +89,7 @@ async def test_multiple_data_in_frame( await bridge.on_frame(frame) assert bridge.event_count == 2 - # 1 connected lifecycle event + 2 data events = 3 sends - assert gateway._ws.send.call_count == 3 + assert gateway._ws.send.call_count == 2 @pytest.mark.asyncio async def test_filter_drops_duplicate(self, bridge: TelemetryBridge) -> None: @@ -164,7 +161,8 @@ async def _mock_connect() -> None: await bridge.on_frame(frame) gateway.connect.assert_awaited_once() - assert gateway._ws.send.call_count == 1 + # 1 connected lifecycle event (from reconnect) + 1 data event = 2 sends + assert gateway._ws.send.call_count == 2 assert bridge.event_count == 1 assert bridge.drop_count == 0 @@ -282,27 +280,25 @@ class TestBridgeLifecycleEvents: """Tests for node.connected / node.disconnecting lifecycle events.""" @pytest.mark.asyncio - async def test_first_frame_sends_connected_event(self, gateway: GatewayClient) -> None: - """First frame should trigger a node.connected event before data events.""" + async def test_send_connected(self, gateway: GatewayClient) -> None: + """Calling send_connected() sends a node.connected event to the gateway.""" filters = {"Soc": FieldFilter(granularity=0.0, throttle_seconds=0.0)} filt = DualGateFilter(filters) emitter = EventEmitter(client_id="test") bridge = TelemetryBridge(gateway, filt, emitter, vin="VIN1", client_id="test-client") - frame = _make_frame(data=[TelemetryDatum("Soc", 3, 72.0, "float")]) - await bridge.on_frame(frame) + assert await bridge.send_connected() is True - # First send should be the connected event, second the data event - assert gateway._ws.send.call_count == 2 - first_msg = json.loads(gateway._ws.send.call_args_list[0][0][0]) - assert first_msg["method"] == "req:agent" - assert first_msg["params"]["event_type"] == "node.connected" - assert first_msg["params"]["vin"] == "VIN1" - assert first_msg["params"]["source"] == "test-client" + assert gateway._ws.send.call_count == 1 + msg = json.loads(gateway._ws.send.call_args[0][0]) + assert msg["method"] == "req:agent" + assert msg["params"]["event_type"] == "node.connected" + assert msg["params"]["vin"] == "VIN1" + assert msg["params"]["source"] == "test-client" @pytest.mark.asyncio - async def test_connected_event_sent_only_once(self, gateway: GatewayClient) -> None: - """node.connected should only be sent on the first frame.""" + async def test_on_frame_does_not_send_connected(self, gateway: GatewayClient) -> None: + """on_frame() should only send data events, never connected events.""" filters = {"Soc": FieldFilter(granularity=0.0, throttle_seconds=0.0)} filt = DualGateFilter(filters) emitter = EventEmitter(client_id="test") @@ -310,16 +306,18 @@ async def test_connected_event_sent_only_once(self, gateway: GatewayClient) -> N frame1 = _make_frame(data=[TelemetryDatum("Soc", 3, 72.0, "float")]) await bridge.on_frame(frame1) - # 1 connected + 1 data = 2 - assert gateway._ws.send.call_count == 2 + # Only the data event, no connected event + assert gateway._ws.send.call_count == 1 + msg = json.loads(gateway._ws.send.call_args[0][0]) + assert msg["params"]["event_type"] == "battery" frame2 = _make_frame(data=[TelemetryDatum("Soc", 3, 80.0, "float")]) await bridge.on_frame(frame2) - # Only 1 more data event (no second connected) = 3 - assert gateway._ws.send.call_count == 3 + # One more data event + assert gateway._ws.send.call_count == 2 @pytest.mark.asyncio - async def test_connected_event_not_sent_in_dry_run(self, gateway: GatewayClient) -> None: + async def test_send_connected_not_sent_in_dry_run(self, gateway: GatewayClient) -> None: filters = {"Soc": FieldFilter(granularity=0.0, throttle_seconds=0.0)} filt = DualGateFilter(filters) emitter = EventEmitter(client_id="test") @@ -327,29 +325,37 @@ async def test_connected_event_not_sent_in_dry_run(self, gateway: GatewayClient) gateway, filt, emitter, dry_run=True, vin="VIN1", client_id="test" ) - frame = _make_frame(data=[TelemetryDatum("Soc", 3, 72.0, "float")]) - await bridge.on_frame(frame) + # Dry-run is considered success (nothing to send) + assert await bridge.send_connected() is True - # Dry run doesn't send anything via gateway + # Gateway should NOT have been called in dry-run assert gateway._ws.send.call_count == 0 @pytest.mark.asyncio - async def test_connected_event_skipped_when_disconnected(self, gateway: GatewayClient) -> None: - """No connected event if gateway is disconnected.""" - gateway._connected = False + async def test_reconnect_sends_connected(self, gateway: GatewayClient) -> None: + """Successful reconnect in _maybe_reconnect() sends node.connected.""" filters = {"Soc": FieldFilter(granularity=0.0, throttle_seconds=0.0)} filt = DualGateFilter(filters) emitter = EventEmitter(client_id="test") - bridge = TelemetryBridge(gateway, filt, emitter, vin="VIN1", client_id="test") + bridge = TelemetryBridge(gateway, filt, emitter, vin="VIN1", client_id="test-client") + + # Simulate disconnected state so _maybe_reconnect is invoked. + gateway._connected = False - # Patch out reconnect to keep things simple - bridge._reconnect_at = float("inf") + async def _mock_connect() -> None: + gateway._connected = True + + gateway.connect = AsyncMock(side_effect=_mock_connect) # type: ignore[method-assign] + # Trigger reconnect via on_frame with a mapped datum. frame = _make_frame(data=[TelemetryDatum("Soc", 3, 72.0, "float")]) await bridge.on_frame(frame) - # Nothing sent (gateway is disconnected) - assert gateway._ws.send.call_count == 0 + gateway.connect.assert_awaited_once() + # 1 connected lifecycle event (from reconnect) + 1 data event = 2 + assert gateway._ws.send.call_count == 2 + first_msg = json.loads(gateway._ws.send.call_args_list[0][0][0]) + assert first_msg["params"]["event_type"] == "node.connected" @pytest.mark.asyncio async def test_send_disconnecting(self, gateway: GatewayClient) -> None: @@ -379,6 +385,31 @@ async def test_send_disconnecting_dry_run_is_noop(self, gateway: GatewayClient) await bridge.send_disconnecting() assert gateway._ws.send.call_count == 0 + @pytest.mark.asyncio + async def test_send_connected_failure_does_not_raise(self, gateway: GatewayClient) -> None: + """Connected event failure should not crash, returns False.""" + gateway._ws.send = AsyncMock(side_effect=ConnectionError("broken")) + filters = {} + filt = DualGateFilter(filters) + emitter = EventEmitter(client_id="test") + bridge = TelemetryBridge(gateway, filt, emitter, vin="VIN1", client_id="test") + + # Should not raise, but should report failure + assert await bridge.send_connected() is False + + @pytest.mark.asyncio + async def test_send_connected_skipped_when_disconnected(self, gateway: GatewayClient) -> None: + """send_connected() returns False when gateway is not connected.""" + gateway._connected = False + filters = {} + filt = DualGateFilter(filters) + emitter = EventEmitter(client_id="test") + bridge = TelemetryBridge(gateway, filt, emitter, vin="VIN1", client_id="test") + + assert await bridge.send_connected() is False + + assert gateway._ws.send.call_count == 0 + @pytest.mark.asyncio async def test_send_disconnecting_failure_does_not_raise(self, gateway: GatewayClient) -> None: """Disconnecting event failure should not crash shutdown.""" diff --git a/tests/openclaw/test_config.py b/tests/openclaw/test_config.py index 43bfa3a..aa0e6a0 100644 --- a/tests/openclaw/test_config.py +++ b/tests/openclaw/test_config.py @@ -116,10 +116,8 @@ def test_merge_none_keeps_original(self) -> None: class TestNodeCapabilities: def test_defaults(self) -> None: caps = NodeCapabilities() - assert "location.get" in caps.reads - assert "battery.get" in caps.reads - assert "door.lock" in caps.writes - assert "flash_lights" in caps.writes + assert caps.reads == ["location.get"] + assert caps.writes == ["system.run"] def test_custom_reads(self) -> None: caps = NodeCapabilities(reads=["custom.read"]) @@ -156,13 +154,23 @@ def test_all_commands_preserves_order(self) -> None: # a.get appears in both reads and writes — deduplicated, reads-first order assert caps.all_commands == ["a.get", "b.do"] + def test_empty_capabilities(self) -> None: + caps = NodeCapabilities(reads=[], writes=[]) + assert caps.all_commands == [] + assert caps.caps == [] + assert caps.permissions == {} + params = caps.to_connect_params() + assert params["commands"] == [] + assert params["caps"] == [] + assert params["permissions"] == {} + class TestBridgeConfigCapabilities: def test_default_capabilities(self) -> None: cfg = BridgeConfig() assert isinstance(cfg.capabilities, NodeCapabilities) - assert len(cfg.capabilities.reads) == 8 - assert len(cfg.capabilities.writes) == 21 + assert len(cfg.capabilities.reads) == 1 + assert len(cfg.capabilities.writes) == 1 def test_custom_capabilities_from_json(self, tmp_path: Path) -> None: config_file = tmp_path / "bridge.json" diff --git a/tests/openclaw/test_gateway.py b/tests/openclaw/test_gateway.py index 96b3add..de15f30 100644 --- a/tests/openclaw/test_gateway.py +++ b/tests/openclaw/test_gateway.py @@ -543,8 +543,8 @@ async def test_capabilities_in_connect_params(self) -> None: @pytest.mark.asyncio async def test_default_capabilities_sends_all_commands(self) -> None: - """Default NodeCapabilities sends all 20 commands in the connect params.""" - caps = NodeCapabilities() # defaults: 6 reads + 14 writes + """Default NodeCapabilities sends location.get + system.run.""" + caps = NodeCapabilities() # defaults: 1 read + 1 write mock_ws = AsyncMock() mock_ws.recv = AsyncMock(side_effect=[_challenge(), _hello_ok()]) mock_ws.send = AsyncMock() @@ -558,33 +558,19 @@ async def test_default_capabilities_sends_all_commands(self) -> None: permissions = sent["params"]["permissions"] caps_list = sent["params"]["caps"] - # All 29 commands must be present - # (6 reads + 14 writes + 4 trigger + 4 convenience + system.run) - assert len(commands) == 29 + # 2 commands: location.get (read) + system.run (write) + assert len(commands) == 2 assert "location.get" in commands - assert "battery.get" in commands - assert "temperature.get" in commands - assert "charge_state.get" in commands - assert "speed.get" in commands - assert "security.get" in commands - assert "door.lock" in commands - assert "climate.set_temp" in commands - assert "charge.start" in commands - assert "sentry.on" in commands - assert "trigger.create" in commands - assert "trigger.delete" in commands - assert "trigger.list" in commands - assert "trigger.poll" in commands - assert "battery.trigger" in commands assert "system.run" in commands # permissions must match commands 1:1 - assert len(permissions) == 29 + assert len(permissions) == 2 assert all(permissions[cmd] is True for cmd in commands) - # caps should include all unique prefixes - # 14 original + trigger, cabin_temp, outside_temp, system = 18 - assert len(caps_list) == 18 + # caps: location, system + assert len(caps_list) == 2 + assert "location" in caps_list + assert "system" in caps_list @pytest.mark.asyncio async def test_no_capabilities_omits_caps(self) -> None: @@ -971,3 +957,57 @@ async def _forever() -> None: assert gw._recv_task is None assert gw._connected is False + + +class TestReconnectCallback: + @pytest.mark.asyncio + async def test_reconnect_callback_called(self) -> None: + """on_reconnect callback is awaited after successful reconnect in receive loop.""" + on_reconnect = AsyncMock() + + gw = GatewayClient("ws://test:1234", on_request=AsyncMock(), on_reconnect=on_reconnect) + gw._ws = _MockWebSocket([]) # empty → iterator exhausts immediately + gw._connected = True + + reconnect_count = 0 + + async def _fake_reconnect() -> bool: + nonlocal reconnect_count + reconnect_count += 1 + if reconnect_count == 1: + gw._ws = _MockWebSocket([]) + gw._connected = True + return True + return False + + with patch.object(gw, "_try_reconnect", side_effect=_fake_reconnect): + await gw._receive_loop() + + on_reconnect.assert_awaited_once() + + @pytest.mark.asyncio + async def test_reconnect_callback_failure_does_not_break_loop(self) -> None: + """on_reconnect callback failure doesn't crash the receive loop.""" + on_reconnect = AsyncMock(side_effect=RuntimeError("callback boom")) + + gw = GatewayClient("ws://test:1234", on_request=AsyncMock(), on_reconnect=on_reconnect) + gw._ws = _MockWebSocket([]) + gw._connected = True + + reconnect_count = 0 + + async def _fake_reconnect() -> bool: + nonlocal reconnect_count + reconnect_count += 1 + if reconnect_count == 1: + gw._ws = _MockWebSocket([]) + gw._connected = True + return True + return False + + with patch.object(gw, "_try_reconnect", side_effect=_fake_reconnect): + await gw._receive_loop() + + # Callback was called despite the error, and the loop continued + on_reconnect.assert_awaited_once() + assert reconnect_count == 2 # Loop continued after callback failure