tescmd 0.4.0__tar.gz → 0.5.0__tar.gz
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-0.4.0 → tescmd-0.5.0}/.github/workflows/test.yml +2 -1
- {tescmd-0.4.0 → tescmd-0.5.0}/CHANGELOG.md +19 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/PKG-INFO +2 -1
- {tescmd-0.4.0 → tescmd-0.5.0}/pyproject.toml +3 -1
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/__init__.py +1 -1
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/key.py +2 -1
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/openclaw.py +3 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/serve.py +5 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/setup.py +6 -6
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/deploy/tailscale_serve.py +1 -1
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/bridge.py +45 -15
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/config.py +9 -32
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/dispatcher.py +1 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/gateway.py +20 -1
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/triggers/models.py +2 -2
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_bridge.py +66 -35
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_config.py +14 -6
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_gateway.py +63 -23
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_tui.py +7 -6
- {tescmd-0.4.0 → tescmd-0.5.0}/.env.example +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/.github/workflows/publish.yml +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/.gitignore +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/CLAUDE.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/LICENSE +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/README.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/api-costs.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/architecture.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/authentication.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/bot-integration.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/commands.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/development.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/faq.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/mcp.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/openclaw.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/setup.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/docs/vehicle-command-protocol.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/images/tescmd_header.jpeg +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/images/tescmd_logo.jpeg +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/images/tescmd_mcp.png +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/images/tescmd_serve.png +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/images/tescmd_waypoints.png +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/scripts/validate_fleet_api.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/skills/tescmd/SKILL.md +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/spec/fleet_api_spec.json +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/__main__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/_internal/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/_internal/async_utils.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/_internal/permissions.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/_internal/vin.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/charging.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/client.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/command.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/energy.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/errors.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/partner.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/sharing.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/signed_command.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/user.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/vehicle.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/auth/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/auth/oauth.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/auth/server.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/auth/token_store.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/ble/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cache/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cache/keys.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cache/response_cache.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/_client.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/_options.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/auth.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/billing.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/cache.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/charge.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/climate.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/energy.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/main.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/mcp_cmd.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/media.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/nav.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/partner.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/raw.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/security.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/sharing.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/software.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/status.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/trunk.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/user.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/vehicle.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/config/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/crypto/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/crypto/ecdh.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/crypto/keys.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/crypto/schnorr.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/deploy/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/deploy/github_pages.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/mcp/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/mcp/server.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/auth.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/command.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/config.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/energy.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/sharing.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/user.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/vehicle.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/emitter.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/filters.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/telemetry_store.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/output/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/output/formatter.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/output/json_output.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/output/rich_output.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/commands.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/encoder.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/metadata.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/payloads.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/protobuf/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/protobuf/messages.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/session.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/signer.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/py.typed +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/cache_sink.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/csv_sink.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/dashboard.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/decoder.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/fanout.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/fields.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/flatbuf.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/mapper.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_alert.proto +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_alert_pb2.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_alert_pb2.pyi +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_connectivity.proto +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_connectivity_pb2.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_data.proto +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_data_pb2.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_data_pb2.pyi +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_error.proto +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_error_pb2.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_error_pb2.pyi +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_metric.proto +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_metric_pb2.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_metric_pb2.pyi +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/server.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/setup.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/tailscale.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/tui.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/triggers/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/triggers/manager.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/_internal/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/_internal/test_async_utils.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/_internal/test_vin.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_client.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_command_api.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_energy_api.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_partner_api.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_sharing_api.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_signed_command.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_user_api.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_vehicle_api.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_oauth.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_oauth_extended.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_server.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_token_store.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_token_store_fallback.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_token_store_file.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cache/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cache/test_generic_cache.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cache/test_keys.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cache/test_response_cache.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/_helpers.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/conftest.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_auth.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_auth_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_bugfixes.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_cache.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_cached_api_call.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_charge_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_cli_integration.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_climate_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_e2e_smoke.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_energy.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_energy_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_error_handlers.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_key.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_key_enroll.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_key_unenroll.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_main_errors.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_media.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_media_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_nav.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_nav_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_partner.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_raw.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_raw_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_security_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_setup.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_setup_scope_check.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_sharing.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_sharing_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_software.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_software_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_status_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_tier_enforcement.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_trunk_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_user.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_user_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_vcsec_guard.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_vehicle_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_vehicle_power_exec.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_verbose.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_wake_confirmation.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/conftest.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/crypto/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/crypto/test_ecdh.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/crypto/test_keys.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/crypto/test_schnorr.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/deploy/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/deploy/test_github_pages.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/deploy/test_tailscale_serve.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/integration/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/integration/test_serve.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/mcp/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/mcp/test_server.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/mcp/test_tools.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_auth.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_config.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_energy.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_sharing.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_user_models.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_vehicle.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_dispatcher.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_emitter.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_filters.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_telemetry_store.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/output/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/output/test_formatter.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/output/test_json_output.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/output/test_rich_output.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/conftest.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_boombox.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_commands.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_encoder.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_metadata.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_navigation.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_session.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_signer.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/conftest.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_cache_sink.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_csv_sink.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_dashboard.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_decoder.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_fanout.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_fields.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_flatbuf.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_mapper.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_server.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_stream_cmd.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_tailscale.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/triggers/__init__.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/triggers/test_manager.py +0 -0
- {tescmd-0.4.0 → tescmd-0.5.0}/tests/triggers/test_models.py +0 -0
|
@@ -8,6 +8,7 @@ on:
|
|
|
8
8
|
jobs:
|
|
9
9
|
test:
|
|
10
10
|
runs-on: ubuntu-latest
|
|
11
|
+
timeout-minutes: 10
|
|
11
12
|
strategy:
|
|
12
13
|
matrix:
|
|
13
14
|
python-version: ["3.11", "3.12", "3.13"]
|
|
@@ -20,4 +21,4 @@ jobs:
|
|
|
20
21
|
- run: ruff check src/ tests/
|
|
21
22
|
- run: ruff format --check src/ tests/
|
|
22
23
|
- run: mypy src/
|
|
23
|
-
- run: pytest
|
|
24
|
+
- run: pytest -v --timeout=30
|
|
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.0] - 2026-02-03
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **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
|
|
13
|
+
- **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
|
|
14
|
+
- **`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
|
|
15
|
+
- **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
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`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)
|
|
20
|
+
- **`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`
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **`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()`)
|
|
25
|
+
- **`OpenClawPipeline.dispatcher` typing** — changed from `Any` to `CommandDispatcher` for static analysis support
|
|
26
|
+
|
|
8
27
|
## [0.4.0] - 2026-02-02
|
|
9
28
|
|
|
10
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tescmd
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A Python CLI for querying and controlling Tesla vehicles via the Fleet API
|
|
5
5
|
Project-URL: Homepage, https://github.com/oceanswave/tescmd
|
|
6
6
|
Project-URL: Repository, https://github.com/oceanswave/tescmd
|
|
@@ -46,6 +46,7 @@ Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
|
46
46
|
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
47
47
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
48
48
|
Requires-Dist: pytest-httpx>=0.34; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
|
|
49
50
|
Requires-Dist: pytest-xdist>=3.0; extra == 'dev'
|
|
50
51
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
51
52
|
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tescmd"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "A Python CLI for querying and controlling Tesla vehicles via the Fleet API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -49,6 +49,7 @@ dev = [
|
|
|
49
49
|
"pytest-httpx>=0.34",
|
|
50
50
|
"pytest-xdist>=3.0",
|
|
51
51
|
"pytest-cov>=5.0",
|
|
52
|
+
"pytest-timeout>=2.3",
|
|
52
53
|
"ruff>=0.8",
|
|
53
54
|
"mypy>=1.13",
|
|
54
55
|
"mypy-protobuf>=3.6",
|
|
@@ -88,6 +89,7 @@ plugins = ["pydantic.mypy"]
|
|
|
88
89
|
testpaths = ["tests"]
|
|
89
90
|
asyncio_mode = "auto"
|
|
90
91
|
addopts = "-n auto -m 'not e2e'"
|
|
92
|
+
timeout = 30
|
|
91
93
|
markers = [
|
|
92
94
|
"e2e: End-to-end smoke tests against the live Tesla Fleet API (requires TESLA_ACCESS_TOKEN)",
|
|
93
95
|
]
|
|
@@ -604,7 +604,8 @@ async def _cmd_enroll(
|
|
|
604
604
|
formatter.rich.info(" [cyan]tescmd charge status --wake[/cyan]")
|
|
605
605
|
formatter.rich.info("")
|
|
606
606
|
formatter.rich.info(
|
|
607
|
-
"[dim]Tip: The QR code must be scanned on your phone
|
|
607
|
+
"[dim]Tip: The QR code must be scanned on your phone"
|
|
608
|
+
" that has the Tesla app installed.[/dim]"
|
|
608
609
|
)
|
|
609
610
|
|
|
610
611
|
|
|
@@ -139,8 +139,11 @@ async def _cmd_bridge(
|
|
|
139
139
|
if formatter.format != "json":
|
|
140
140
|
formatter.rich.info(f"Connecting to OpenClaw Gateway: {config.gateway_url}")
|
|
141
141
|
await gw.connect_with_backoff(max_attempts=5)
|
|
142
|
+
lifecycle_ok = await bridge.send_connected()
|
|
142
143
|
if formatter.format != "json":
|
|
143
144
|
formatter.rich.info("[green]Connected to gateway.[/green]")
|
|
145
|
+
if not lifecycle_ok:
|
|
146
|
+
formatter.rich.info("[yellow]Warning: node.connected event failed[/yellow]")
|
|
144
147
|
else:
|
|
145
148
|
if formatter.format != "json":
|
|
146
149
|
formatter.rich.info("[yellow]Dry-run mode — events will be logged as JSONL.[/yellow]")
|
|
@@ -401,8 +401,13 @@ async def _cmd_serve(
|
|
|
401
401
|
if is_rich:
|
|
402
402
|
formatter.rich.info(f"Connecting to OpenClaw Gateway: {config.gateway_url}")
|
|
403
403
|
await gw.connect_with_backoff(max_attempts=5)
|
|
404
|
+
lifecycle_ok = await oc_bridge.send_connected()
|
|
404
405
|
if is_rich:
|
|
405
406
|
formatter.rich.info("[green]Connected to OpenClaw gateway.[/green]")
|
|
407
|
+
if not lifecycle_ok:
|
|
408
|
+
formatter.rich.info(
|
|
409
|
+
"[yellow]Warning: node.connected event failed[/yellow]"
|
|
410
|
+
)
|
|
406
411
|
else:
|
|
407
412
|
if is_rich:
|
|
408
413
|
formatter.rich.info(
|
|
@@ -187,15 +187,15 @@ def _check_key_mismatch(
|
|
|
187
187
|
|
|
188
188
|
# Fetch remote key (method-aware)
|
|
189
189
|
if settings.hosting_method == "tailscale":
|
|
190
|
-
from tescmd.deploy
|
|
190
|
+
from tescmd.deploy import tailscale_serve as _ts
|
|
191
191
|
|
|
192
|
-
url = get_key_url(domain)
|
|
193
|
-
remote_pem = fetch_tailscale_key_pem(domain)
|
|
192
|
+
url = _ts.get_key_url(domain)
|
|
193
|
+
remote_pem = _ts.fetch_tailscale_key_pem(domain)
|
|
194
194
|
else:
|
|
195
|
-
from tescmd.deploy
|
|
195
|
+
from tescmd.deploy import github_pages as _gh
|
|
196
196
|
|
|
197
|
-
url = get_key_url(domain)
|
|
198
|
-
remote_pem = fetch_key_pem(domain)
|
|
197
|
+
url = _gh.get_key_url(domain)
|
|
198
|
+
remote_pem = _gh.fetch_key_pem(domain)
|
|
199
199
|
|
|
200
200
|
if remote_pem is not None and remote_pem != pem.strip():
|
|
201
201
|
info("[yellow]The public key on your domain differs from your local key.[/yellow]")
|
|
@@ -37,7 +37,7 @@ POLL_INTERVAL = 3 # seconds
|
|
|
37
37
|
class _KeyRequestHandler(BaseHTTPRequestHandler):
|
|
38
38
|
"""Serve the root (200 OK) and the ``.well-known`` PEM path."""
|
|
39
39
|
|
|
40
|
-
server: KeyServer
|
|
40
|
+
server: KeyServer
|
|
41
41
|
|
|
42
42
|
def do_GET(self) -> None:
|
|
43
43
|
if self.path == "/":
|
|
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from tescmd.cli.main import AppContext
|
|
17
17
|
from tescmd.openclaw.config import BridgeConfig
|
|
18
|
+
from tescmd.openclaw.dispatcher import CommandDispatcher
|
|
18
19
|
from tescmd.openclaw.emitter import EventEmitter
|
|
19
20
|
from tescmd.openclaw.filters import DualGateFilter
|
|
20
21
|
from tescmd.openclaw.gateway import GatewayClient
|
|
@@ -61,7 +62,6 @@ class TelemetryBridge:
|
|
|
61
62
|
self._event_count = 0
|
|
62
63
|
self._drop_count = 0
|
|
63
64
|
self._last_event_time: float | None = None
|
|
64
|
-
self._first_frame_received = False
|
|
65
65
|
self._reconnect_at: float = 0.0
|
|
66
66
|
self._reconnect_backoff: float = _RECONNECT_BASE
|
|
67
67
|
self._shutting_down = False
|
|
@@ -88,8 +88,6 @@ class TelemetryBridge:
|
|
|
88
88
|
logger.info("Attempting OpenClaw gateway reconnection...")
|
|
89
89
|
try:
|
|
90
90
|
await self._gateway.connect()
|
|
91
|
-
self._reconnect_backoff = _RECONNECT_BASE
|
|
92
|
-
logger.info("Reconnected to OpenClaw gateway")
|
|
93
91
|
except Exception:
|
|
94
92
|
self._reconnect_at = now + self._reconnect_backoff
|
|
95
93
|
logger.warning(
|
|
@@ -97,6 +95,13 @@ class TelemetryBridge:
|
|
|
97
95
|
self._reconnect_backoff,
|
|
98
96
|
)
|
|
99
97
|
self._reconnect_backoff = min(self._reconnect_backoff * 2, _RECONNECT_MAX)
|
|
98
|
+
return
|
|
99
|
+
self._reconnect_backoff = _RECONNECT_BASE
|
|
100
|
+
logger.info("Reconnected to OpenClaw gateway")
|
|
101
|
+
try:
|
|
102
|
+
await self.send_connected()
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.warning("Failed to send connected event after reconnect", exc_info=True)
|
|
100
105
|
|
|
101
106
|
def _build_lifecycle_event(self, event_type: str) -> dict[str, Any]:
|
|
102
107
|
"""Build a ``req:agent`` lifecycle event (connecting/disconnecting)."""
|
|
@@ -148,6 +153,30 @@ class TelemetryBridge:
|
|
|
148
153
|
|
|
149
154
|
return _push_trigger_notification
|
|
150
155
|
|
|
156
|
+
async def send_connected(self) -> bool:
|
|
157
|
+
"""Send a ``node.connected`` lifecycle event to the gateway.
|
|
158
|
+
|
|
159
|
+
Returns ``True`` if the event was sent (or skipped due to dry-run),
|
|
160
|
+
``False`` if the gateway was disconnected or the send failed.
|
|
161
|
+
"""
|
|
162
|
+
if self._dry_run:
|
|
163
|
+
return True
|
|
164
|
+
if not self._gateway.is_connected:
|
|
165
|
+
logger.warning("Cannot send node.connected — gateway not connected")
|
|
166
|
+
return False
|
|
167
|
+
event = self._build_lifecycle_event("node.connected")
|
|
168
|
+
try:
|
|
169
|
+
await self._gateway.send_event(event)
|
|
170
|
+
except Exception:
|
|
171
|
+
logger.warning("Failed to send connected event", exc_info=True)
|
|
172
|
+
return False
|
|
173
|
+
# send_event() swallows errors and marks disconnected, so check again.
|
|
174
|
+
if not self._gateway.is_connected:
|
|
175
|
+
logger.warning("Failed to send connected event — gateway disconnected during send")
|
|
176
|
+
return False
|
|
177
|
+
logger.info("Sent node.connected event")
|
|
178
|
+
return True
|
|
179
|
+
|
|
151
180
|
async def send_disconnecting(self) -> None:
|
|
152
181
|
"""Send a ``node.disconnecting`` lifecycle event to the gateway.
|
|
153
182
|
|
|
@@ -175,17 +204,6 @@ class TelemetryBridge:
|
|
|
175
204
|
"""
|
|
176
205
|
now = time.monotonic()
|
|
177
206
|
|
|
178
|
-
# Send node.connected lifecycle event on the very first frame.
|
|
179
|
-
if not self._first_frame_received:
|
|
180
|
-
self._first_frame_received = True
|
|
181
|
-
if not self._dry_run and self._gateway.is_connected:
|
|
182
|
-
lifecycle_event = self._build_lifecycle_event("node.connected")
|
|
183
|
-
try:
|
|
184
|
-
await self._gateway.send_event(lifecycle_event)
|
|
185
|
-
logger.info("Sent node.connected event")
|
|
186
|
-
except Exception:
|
|
187
|
-
logger.warning("Failed to send connected event", exc_info=True)
|
|
188
|
-
|
|
189
207
|
for datum in frame.data:
|
|
190
208
|
if not self._filter.should_emit(datum.field_name, datum.value, now):
|
|
191
209
|
self._drop_count += 1
|
|
@@ -259,7 +277,7 @@ class OpenClawPipeline:
|
|
|
259
277
|
gateway: GatewayClient
|
|
260
278
|
bridge: TelemetryBridge
|
|
261
279
|
telemetry_store: TelemetryStore
|
|
262
|
-
dispatcher:
|
|
280
|
+
dispatcher: CommandDispatcher
|
|
263
281
|
|
|
264
282
|
|
|
265
283
|
def build_openclaw_pipeline(
|
|
@@ -301,6 +319,17 @@ def build_openclaw_pipeline(
|
|
|
301
319
|
|
|
302
320
|
from tescmd import __version__
|
|
303
321
|
|
|
322
|
+
# bridge is assigned below, but the closure captures it by reference —
|
|
323
|
+
# on_reconnect is only called during live reconnection, long after this
|
|
324
|
+
# function returns, so bridge is always initialised by then.
|
|
325
|
+
bridge: TelemetryBridge | None = None
|
|
326
|
+
|
|
327
|
+
async def _on_reconnect() -> None:
|
|
328
|
+
if bridge is not None:
|
|
329
|
+
await bridge.send_connected()
|
|
330
|
+
else:
|
|
331
|
+
logger.error("on_reconnect fired but bridge is None — this should never happen")
|
|
332
|
+
|
|
304
333
|
gateway = GatewayClient(
|
|
305
334
|
config.gateway_url,
|
|
306
335
|
token=config.gateway_token,
|
|
@@ -310,6 +339,7 @@ def build_openclaw_pipeline(
|
|
|
310
339
|
model_identifier=vin,
|
|
311
340
|
capabilities=config.capabilities,
|
|
312
341
|
on_request=dispatcher.dispatch,
|
|
342
|
+
on_reconnect=_on_reconnect,
|
|
313
343
|
)
|
|
314
344
|
bridge = TelemetryBridge(
|
|
315
345
|
gateway,
|
|
@@ -12,8 +12,16 @@ from pydantic import BaseModel, Field
|
|
|
12
12
|
class NodeCapabilities(BaseModel):
|
|
13
13
|
"""Advertised capabilities for the OpenClaw node role.
|
|
14
14
|
|
|
15
|
+
The node advertises only two commands to the gateway:
|
|
16
|
+
|
|
17
|
+
- ``location.get`` (read) — standard node location capability
|
|
18
|
+
- ``system.run`` (write) — single entry point; the gateway routes all
|
|
19
|
+
invocations through this method and the internal
|
|
20
|
+
:class:`~tescmd.openclaw.dispatcher.CommandDispatcher` fans out to
|
|
21
|
+
the full set of 34 handlers.
|
|
22
|
+
|
|
15
23
|
Maps to the gateway connect schema fields:
|
|
16
|
-
- ``caps``: broad capability categories (
|
|
24
|
+
- ``caps``: broad capability categories (``"location"``, ``"system"``)
|
|
17
25
|
- ``commands``: specific method names the node can handle
|
|
18
26
|
- ``permissions``: per-command permission booleans
|
|
19
27
|
|
|
@@ -23,39 +31,8 @@ class NodeCapabilities(BaseModel):
|
|
|
23
31
|
|
|
24
32
|
reads: list[str] = [
|
|
25
33
|
"location.get",
|
|
26
|
-
"battery.get",
|
|
27
|
-
"temperature.get",
|
|
28
|
-
"speed.get",
|
|
29
|
-
"charge_state.get",
|
|
30
|
-
"security.get",
|
|
31
|
-
# Trigger reads
|
|
32
|
-
"trigger.list",
|
|
33
|
-
"trigger.poll",
|
|
34
34
|
]
|
|
35
35
|
writes: list[str] = [
|
|
36
|
-
"door.lock",
|
|
37
|
-
"door.unlock",
|
|
38
|
-
"climate.on",
|
|
39
|
-
"climate.off",
|
|
40
|
-
"climate.set_temp",
|
|
41
|
-
"charge.start",
|
|
42
|
-
"charge.stop",
|
|
43
|
-
"charge.set_limit",
|
|
44
|
-
"trunk.open",
|
|
45
|
-
"frunk.open",
|
|
46
|
-
"flash_lights",
|
|
47
|
-
"honk_horn",
|
|
48
|
-
"sentry.on",
|
|
49
|
-
"sentry.off",
|
|
50
|
-
# Trigger writes
|
|
51
|
-
"trigger.create",
|
|
52
|
-
"trigger.delete",
|
|
53
|
-
# Convenience trigger aliases
|
|
54
|
-
"cabin_temp.trigger",
|
|
55
|
-
"outside_temp.trigger",
|
|
56
|
-
"battery.trigger",
|
|
57
|
-
"location.trigger",
|
|
58
|
-
# Meta-dispatch
|
|
59
36
|
"system.run",
|
|
60
37
|
]
|
|
61
38
|
|
|
@@ -438,6 +438,7 @@ class CommandDispatcher:
|
|
|
438
438
|
resolved = _METHOD_ALIASES.get(method, method)
|
|
439
439
|
if resolved == "system.run":
|
|
440
440
|
raise ValueError("system.run cannot invoke itself")
|
|
441
|
+
logger.info("system.run → %s", resolved)
|
|
441
442
|
inner_params = params.get("params", {})
|
|
442
443
|
result = await self.dispatch({"method": resolved, "params": inner_params})
|
|
443
444
|
if result is None:
|
|
@@ -229,6 +229,7 @@ class GatewayClient:
|
|
|
229
229
|
model_identifier: str | None = None,
|
|
230
230
|
capabilities: NodeCapabilities | None = None,
|
|
231
231
|
on_request: Callable[[dict[str, Any]], Awaitable[dict[str, Any] | None]] | None = None,
|
|
232
|
+
on_reconnect: Callable[[], Awaitable[None]] | None = None,
|
|
232
233
|
) -> None:
|
|
233
234
|
self._url = url
|
|
234
235
|
self._token = token
|
|
@@ -243,6 +244,7 @@ class GatewayClient:
|
|
|
243
244
|
self._model_identifier = model_identifier or "tescmd"
|
|
244
245
|
self._capabilities = capabilities
|
|
245
246
|
self._on_request = on_request
|
|
247
|
+
self._on_reconnect = on_reconnect
|
|
246
248
|
self._ws: ClientConnection | None = None
|
|
247
249
|
self._connected = False
|
|
248
250
|
self._send_count = 0
|
|
@@ -523,6 +525,12 @@ class GatewayClient:
|
|
|
523
525
|
logger.error("Reconnection failed — receive loop exiting")
|
|
524
526
|
break
|
|
525
527
|
|
|
528
|
+
if self._on_reconnect is not None:
|
|
529
|
+
try:
|
|
530
|
+
await self._on_reconnect()
|
|
531
|
+
except Exception:
|
|
532
|
+
logger.warning("on_reconnect callback failed", exc_info=True)
|
|
533
|
+
|
|
526
534
|
async def _try_reconnect(self) -> bool:
|
|
527
535
|
"""Attempt to re-establish the gateway connection with exponential backoff.
|
|
528
536
|
|
|
@@ -544,7 +552,6 @@ class GatewayClient:
|
|
|
544
552
|
invoke_id = payload.get("id", "")
|
|
545
553
|
command = payload.get("command", "")
|
|
546
554
|
params_json = payload.get("paramsJSON", "{}")
|
|
547
|
-
logger.info("Invoke request: id=%s command=%s", invoke_id, command)
|
|
548
555
|
|
|
549
556
|
if not self._on_request:
|
|
550
557
|
await self._send_invoke_result(invoke_id, ok=False, error="no handler configured")
|
|
@@ -563,6 +570,18 @@ class GatewayClient:
|
|
|
563
570
|
)
|
|
564
571
|
params = {}
|
|
565
572
|
|
|
573
|
+
# Log with the real command name — for system.run, peek at the
|
|
574
|
+
# inner method so the activity log shows what's actually invoked.
|
|
575
|
+
if command == "system.run":
|
|
576
|
+
inner = params.get("method", "") or params.get("command", "")
|
|
577
|
+
if isinstance(inner, list):
|
|
578
|
+
inner = inner[0] if inner else ""
|
|
579
|
+
logger.info(
|
|
580
|
+
"Invoke request: id=%s command=%s (via system.run)", invoke_id, inner or "?"
|
|
581
|
+
)
|
|
582
|
+
else:
|
|
583
|
+
logger.info("Invoke request: id=%s command=%s", invoke_id, command)
|
|
584
|
+
|
|
566
585
|
# Build the message dict the dispatcher expects
|
|
567
586
|
dispatch_msg: dict[str, Any] = {
|
|
568
587
|
"method": command,
|
|
@@ -8,13 +8,13 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import uuid
|
|
10
10
|
from datetime import UTC, datetime
|
|
11
|
-
from enum import
|
|
11
|
+
from enum import StrEnum
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
14
|
from pydantic import BaseModel, Field, model_validator
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
class TriggerOperator(
|
|
17
|
+
class TriggerOperator(StrEnum):
|
|
18
18
|
"""Supported comparison operators for trigger conditions."""
|
|
19
19
|
|
|
20
20
|
LT = "lt"
|
|
@@ -61,10 +61,8 @@ class TestBridgeOnFrame:
|
|
|
61
61
|
|
|
62
62
|
assert bridge.event_count == 1
|
|
63
63
|
assert bridge.drop_count == 0
|
|
64
|
-
|
|
65
|
-
assert gateway._ws.send.call_count == 2
|
|
64
|
+
assert gateway._ws.send.call_count == 1
|
|
66
65
|
|
|
67
|
-
# Last send should be the data event
|
|
68
66
|
sent = json.loads(gateway._ws.send.call_args[0][0])
|
|
69
67
|
assert sent["method"] == "req:agent"
|
|
70
68
|
assert sent["params"]["event_type"] == "battery"
|
|
@@ -91,8 +89,7 @@ class TestBridgeOnFrame:
|
|
|
91
89
|
await bridge.on_frame(frame)
|
|
92
90
|
|
|
93
91
|
assert bridge.event_count == 2
|
|
94
|
-
|
|
95
|
-
assert gateway._ws.send.call_count == 3
|
|
92
|
+
assert gateway._ws.send.call_count == 2
|
|
96
93
|
|
|
97
94
|
@pytest.mark.asyncio
|
|
98
95
|
async def test_filter_drops_duplicate(self, bridge: TelemetryBridge) -> None:
|
|
@@ -164,7 +161,8 @@ class TestBridgeReconnection:
|
|
|
164
161
|
await bridge.on_frame(frame)
|
|
165
162
|
|
|
166
163
|
gateway.connect.assert_awaited_once()
|
|
167
|
-
|
|
164
|
+
# 1 connected lifecycle event (from reconnect) + 1 data event = 2 sends
|
|
165
|
+
assert gateway._ws.send.call_count == 2
|
|
168
166
|
assert bridge.event_count == 1
|
|
169
167
|
assert bridge.drop_count == 0
|
|
170
168
|
|
|
@@ -282,27 +280,25 @@ class TestBridgeLifecycleEvents:
|
|
|
282
280
|
"""Tests for node.connected / node.disconnecting lifecycle events."""
|
|
283
281
|
|
|
284
282
|
@pytest.mark.asyncio
|
|
285
|
-
async def
|
|
286
|
-
"""
|
|
283
|
+
async def test_send_connected(self, gateway: GatewayClient) -> None:
|
|
284
|
+
"""Calling send_connected() sends a node.connected event to the gateway."""
|
|
287
285
|
filters = {"Soc": FieldFilter(granularity=0.0, throttle_seconds=0.0)}
|
|
288
286
|
filt = DualGateFilter(filters)
|
|
289
287
|
emitter = EventEmitter(client_id="test")
|
|
290
288
|
bridge = TelemetryBridge(gateway, filt, emitter, vin="VIN1", client_id="test-client")
|
|
291
289
|
|
|
292
|
-
|
|
293
|
-
await bridge.on_frame(frame)
|
|
290
|
+
assert await bridge.send_connected() is True
|
|
294
291
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
assert
|
|
299
|
-
assert
|
|
300
|
-
assert
|
|
301
|
-
assert first_msg["params"]["source"] == "test-client"
|
|
292
|
+
assert gateway._ws.send.call_count == 1
|
|
293
|
+
msg = json.loads(gateway._ws.send.call_args[0][0])
|
|
294
|
+
assert msg["method"] == "req:agent"
|
|
295
|
+
assert msg["params"]["event_type"] == "node.connected"
|
|
296
|
+
assert msg["params"]["vin"] == "VIN1"
|
|
297
|
+
assert msg["params"]["source"] == "test-client"
|
|
302
298
|
|
|
303
299
|
@pytest.mark.asyncio
|
|
304
|
-
async def
|
|
305
|
-
"""
|
|
300
|
+
async def test_on_frame_does_not_send_connected(self, gateway: GatewayClient) -> None:
|
|
301
|
+
"""on_frame() should only send data events, never connected events."""
|
|
306
302
|
filters = {"Soc": FieldFilter(granularity=0.0, throttle_seconds=0.0)}
|
|
307
303
|
filt = DualGateFilter(filters)
|
|
308
304
|
emitter = EventEmitter(client_id="test")
|
|
@@ -310,16 +306,18 @@ class TestBridgeLifecycleEvents:
|
|
|
310
306
|
|
|
311
307
|
frame1 = _make_frame(data=[TelemetryDatum("Soc", 3, 72.0, "float")])
|
|
312
308
|
await bridge.on_frame(frame1)
|
|
313
|
-
#
|
|
314
|
-
assert gateway._ws.send.call_count ==
|
|
309
|
+
# Only the data event, no connected event
|
|
310
|
+
assert gateway._ws.send.call_count == 1
|
|
311
|
+
msg = json.loads(gateway._ws.send.call_args[0][0])
|
|
312
|
+
assert msg["params"]["event_type"] == "battery"
|
|
315
313
|
|
|
316
314
|
frame2 = _make_frame(data=[TelemetryDatum("Soc", 3, 80.0, "float")])
|
|
317
315
|
await bridge.on_frame(frame2)
|
|
318
|
-
#
|
|
319
|
-
assert gateway._ws.send.call_count ==
|
|
316
|
+
# One more data event
|
|
317
|
+
assert gateway._ws.send.call_count == 2
|
|
320
318
|
|
|
321
319
|
@pytest.mark.asyncio
|
|
322
|
-
async def
|
|
320
|
+
async def test_send_connected_not_sent_in_dry_run(self, gateway: GatewayClient) -> None:
|
|
323
321
|
filters = {"Soc": FieldFilter(granularity=0.0, throttle_seconds=0.0)}
|
|
324
322
|
filt = DualGateFilter(filters)
|
|
325
323
|
emitter = EventEmitter(client_id="test")
|
|
@@ -327,29 +325,37 @@ class TestBridgeLifecycleEvents:
|
|
|
327
325
|
gateway, filt, emitter, dry_run=True, vin="VIN1", client_id="test"
|
|
328
326
|
)
|
|
329
327
|
|
|
330
|
-
|
|
331
|
-
await bridge.
|
|
328
|
+
# Dry-run is considered success (nothing to send)
|
|
329
|
+
assert await bridge.send_connected() is True
|
|
332
330
|
|
|
333
|
-
#
|
|
331
|
+
# Gateway should NOT have been called in dry-run
|
|
334
332
|
assert gateway._ws.send.call_count == 0
|
|
335
333
|
|
|
336
334
|
@pytest.mark.asyncio
|
|
337
|
-
async def
|
|
338
|
-
"""
|
|
339
|
-
gateway._connected = False
|
|
335
|
+
async def test_reconnect_sends_connected(self, gateway: GatewayClient) -> None:
|
|
336
|
+
"""Successful reconnect in _maybe_reconnect() sends node.connected."""
|
|
340
337
|
filters = {"Soc": FieldFilter(granularity=0.0, throttle_seconds=0.0)}
|
|
341
338
|
filt = DualGateFilter(filters)
|
|
342
339
|
emitter = EventEmitter(client_id="test")
|
|
343
|
-
bridge = TelemetryBridge(gateway, filt, emitter, vin="VIN1", client_id="test")
|
|
340
|
+
bridge = TelemetryBridge(gateway, filt, emitter, vin="VIN1", client_id="test-client")
|
|
341
|
+
|
|
342
|
+
# Simulate disconnected state so _maybe_reconnect is invoked.
|
|
343
|
+
gateway._connected = False
|
|
344
344
|
|
|
345
|
-
|
|
346
|
-
|
|
345
|
+
async def _mock_connect() -> None:
|
|
346
|
+
gateway._connected = True
|
|
347
|
+
|
|
348
|
+
gateway.connect = AsyncMock(side_effect=_mock_connect) # type: ignore[method-assign]
|
|
347
349
|
|
|
350
|
+
# Trigger reconnect via on_frame with a mapped datum.
|
|
348
351
|
frame = _make_frame(data=[TelemetryDatum("Soc", 3, 72.0, "float")])
|
|
349
352
|
await bridge.on_frame(frame)
|
|
350
353
|
|
|
351
|
-
|
|
352
|
-
|
|
354
|
+
gateway.connect.assert_awaited_once()
|
|
355
|
+
# 1 connected lifecycle event (from reconnect) + 1 data event = 2
|
|
356
|
+
assert gateway._ws.send.call_count == 2
|
|
357
|
+
first_msg = json.loads(gateway._ws.send.call_args_list[0][0][0])
|
|
358
|
+
assert first_msg["params"]["event_type"] == "node.connected"
|
|
353
359
|
|
|
354
360
|
@pytest.mark.asyncio
|
|
355
361
|
async def test_send_disconnecting(self, gateway: GatewayClient) -> None:
|
|
@@ -379,6 +385,31 @@ class TestBridgeLifecycleEvents:
|
|
|
379
385
|
await bridge.send_disconnecting()
|
|
380
386
|
assert gateway._ws.send.call_count == 0
|
|
381
387
|
|
|
388
|
+
@pytest.mark.asyncio
|
|
389
|
+
async def test_send_connected_failure_does_not_raise(self, gateway: GatewayClient) -> None:
|
|
390
|
+
"""Connected event failure should not crash, returns False."""
|
|
391
|
+
gateway._ws.send = AsyncMock(side_effect=ConnectionError("broken"))
|
|
392
|
+
filters = {}
|
|
393
|
+
filt = DualGateFilter(filters)
|
|
394
|
+
emitter = EventEmitter(client_id="test")
|
|
395
|
+
bridge = TelemetryBridge(gateway, filt, emitter, vin="VIN1", client_id="test")
|
|
396
|
+
|
|
397
|
+
# Should not raise, but should report failure
|
|
398
|
+
assert await bridge.send_connected() is False
|
|
399
|
+
|
|
400
|
+
@pytest.mark.asyncio
|
|
401
|
+
async def test_send_connected_skipped_when_disconnected(self, gateway: GatewayClient) -> None:
|
|
402
|
+
"""send_connected() returns False when gateway is not connected."""
|
|
403
|
+
gateway._connected = False
|
|
404
|
+
filters = {}
|
|
405
|
+
filt = DualGateFilter(filters)
|
|
406
|
+
emitter = EventEmitter(client_id="test")
|
|
407
|
+
bridge = TelemetryBridge(gateway, filt, emitter, vin="VIN1", client_id="test")
|
|
408
|
+
|
|
409
|
+
assert await bridge.send_connected() is False
|
|
410
|
+
|
|
411
|
+
assert gateway._ws.send.call_count == 0
|
|
412
|
+
|
|
382
413
|
@pytest.mark.asyncio
|
|
383
414
|
async def test_send_disconnecting_failure_does_not_raise(self, gateway: GatewayClient) -> None:
|
|
384
415
|
"""Disconnecting event failure should not crash shutdown."""
|
|
@@ -116,10 +116,8 @@ class TestBridgeConfigMerge:
|
|
|
116
116
|
class TestNodeCapabilities:
|
|
117
117
|
def test_defaults(self) -> None:
|
|
118
118
|
caps = NodeCapabilities()
|
|
119
|
-
assert "location.get"
|
|
120
|
-
assert
|
|
121
|
-
assert "door.lock" in caps.writes
|
|
122
|
-
assert "flash_lights" in caps.writes
|
|
119
|
+
assert caps.reads == ["location.get"]
|
|
120
|
+
assert caps.writes == ["system.run"]
|
|
123
121
|
|
|
124
122
|
def test_custom_reads(self) -> None:
|
|
125
123
|
caps = NodeCapabilities(reads=["custom.read"])
|
|
@@ -156,13 +154,23 @@ class TestNodeCapabilities:
|
|
|
156
154
|
# a.get appears in both reads and writes — deduplicated, reads-first order
|
|
157
155
|
assert caps.all_commands == ["a.get", "b.do"]
|
|
158
156
|
|
|
157
|
+
def test_empty_capabilities(self) -> None:
|
|
158
|
+
caps = NodeCapabilities(reads=[], writes=[])
|
|
159
|
+
assert caps.all_commands == []
|
|
160
|
+
assert caps.caps == []
|
|
161
|
+
assert caps.permissions == {}
|
|
162
|
+
params = caps.to_connect_params()
|
|
163
|
+
assert params["commands"] == []
|
|
164
|
+
assert params["caps"] == []
|
|
165
|
+
assert params["permissions"] == {}
|
|
166
|
+
|
|
159
167
|
|
|
160
168
|
class TestBridgeConfigCapabilities:
|
|
161
169
|
def test_default_capabilities(self) -> None:
|
|
162
170
|
cfg = BridgeConfig()
|
|
163
171
|
assert isinstance(cfg.capabilities, NodeCapabilities)
|
|
164
|
-
assert len(cfg.capabilities.reads) ==
|
|
165
|
-
assert len(cfg.capabilities.writes) ==
|
|
172
|
+
assert len(cfg.capabilities.reads) == 1
|
|
173
|
+
assert len(cfg.capabilities.writes) == 1
|
|
166
174
|
|
|
167
175
|
def test_custom_capabilities_from_json(self, tmp_path: Path) -> None:
|
|
168
176
|
config_file = tmp_path / "bridge.json"
|