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.
Files changed (277) hide show
  1. {tescmd-0.4.0 → tescmd-0.5.0}/.github/workflows/test.yml +2 -1
  2. {tescmd-0.4.0 → tescmd-0.5.0}/CHANGELOG.md +19 -0
  3. {tescmd-0.4.0 → tescmd-0.5.0}/PKG-INFO +2 -1
  4. {tescmd-0.4.0 → tescmd-0.5.0}/pyproject.toml +3 -1
  5. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/__init__.py +1 -1
  6. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/key.py +2 -1
  7. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/openclaw.py +3 -0
  8. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/serve.py +5 -0
  9. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/setup.py +6 -6
  10. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/deploy/tailscale_serve.py +1 -1
  11. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/bridge.py +45 -15
  12. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/config.py +9 -32
  13. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/dispatcher.py +1 -0
  14. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/gateway.py +20 -1
  15. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/triggers/models.py +2 -2
  16. {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_bridge.py +66 -35
  17. {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_config.py +14 -6
  18. {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_gateway.py +63 -23
  19. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_tui.py +7 -6
  20. {tescmd-0.4.0 → tescmd-0.5.0}/.env.example +0 -0
  21. {tescmd-0.4.0 → tescmd-0.5.0}/.github/workflows/publish.yml +0 -0
  22. {tescmd-0.4.0 → tescmd-0.5.0}/.gitignore +0 -0
  23. {tescmd-0.4.0 → tescmd-0.5.0}/CLAUDE.md +0 -0
  24. {tescmd-0.4.0 → tescmd-0.5.0}/LICENSE +0 -0
  25. {tescmd-0.4.0 → tescmd-0.5.0}/README.md +0 -0
  26. {tescmd-0.4.0 → tescmd-0.5.0}/docs/api-costs.md +0 -0
  27. {tescmd-0.4.0 → tescmd-0.5.0}/docs/architecture.md +0 -0
  28. {tescmd-0.4.0 → tescmd-0.5.0}/docs/authentication.md +0 -0
  29. {tescmd-0.4.0 → tescmd-0.5.0}/docs/bot-integration.md +0 -0
  30. {tescmd-0.4.0 → tescmd-0.5.0}/docs/commands.md +0 -0
  31. {tescmd-0.4.0 → tescmd-0.5.0}/docs/development.md +0 -0
  32. {tescmd-0.4.0 → tescmd-0.5.0}/docs/faq.md +0 -0
  33. {tescmd-0.4.0 → tescmd-0.5.0}/docs/mcp.md +0 -0
  34. {tescmd-0.4.0 → tescmd-0.5.0}/docs/openclaw.md +0 -0
  35. {tescmd-0.4.0 → tescmd-0.5.0}/docs/setup.md +0 -0
  36. {tescmd-0.4.0 → tescmd-0.5.0}/docs/vehicle-command-protocol.md +0 -0
  37. {tescmd-0.4.0 → tescmd-0.5.0}/images/tescmd_header.jpeg +0 -0
  38. {tescmd-0.4.0 → tescmd-0.5.0}/images/tescmd_logo.jpeg +0 -0
  39. {tescmd-0.4.0 → tescmd-0.5.0}/images/tescmd_mcp.png +0 -0
  40. {tescmd-0.4.0 → tescmd-0.5.0}/images/tescmd_serve.png +0 -0
  41. {tescmd-0.4.0 → tescmd-0.5.0}/images/tescmd_waypoints.png +0 -0
  42. {tescmd-0.4.0 → tescmd-0.5.0}/scripts/validate_fleet_api.py +0 -0
  43. {tescmd-0.4.0 → tescmd-0.5.0}/skills/tescmd/SKILL.md +0 -0
  44. {tescmd-0.4.0 → tescmd-0.5.0}/spec/fleet_api_spec.json +0 -0
  45. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/__main__.py +0 -0
  46. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/_internal/__init__.py +0 -0
  47. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/_internal/async_utils.py +0 -0
  48. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/_internal/permissions.py +0 -0
  49. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/_internal/vin.py +0 -0
  50. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/__init__.py +0 -0
  51. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/charging.py +0 -0
  52. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/client.py +0 -0
  53. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/command.py +0 -0
  54. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/energy.py +0 -0
  55. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/errors.py +0 -0
  56. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/partner.py +0 -0
  57. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/sharing.py +0 -0
  58. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/signed_command.py +0 -0
  59. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/user.py +0 -0
  60. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/api/vehicle.py +0 -0
  61. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/auth/__init__.py +0 -0
  62. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/auth/oauth.py +0 -0
  63. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/auth/server.py +0 -0
  64. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/auth/token_store.py +0 -0
  65. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/ble/__init__.py +0 -0
  66. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cache/__init__.py +0 -0
  67. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cache/keys.py +0 -0
  68. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cache/response_cache.py +0 -0
  69. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/__init__.py +0 -0
  70. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/_client.py +0 -0
  71. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/_options.py +0 -0
  72. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/auth.py +0 -0
  73. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/billing.py +0 -0
  74. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/cache.py +0 -0
  75. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/charge.py +0 -0
  76. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/climate.py +0 -0
  77. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/energy.py +0 -0
  78. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/main.py +0 -0
  79. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/mcp_cmd.py +0 -0
  80. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/media.py +0 -0
  81. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/nav.py +0 -0
  82. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/partner.py +0 -0
  83. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/raw.py +0 -0
  84. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/security.py +0 -0
  85. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/sharing.py +0 -0
  86. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/software.py +0 -0
  87. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/status.py +0 -0
  88. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/trunk.py +0 -0
  89. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/user.py +0 -0
  90. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/cli/vehicle.py +0 -0
  91. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/config/__init__.py +0 -0
  92. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/crypto/__init__.py +0 -0
  93. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/crypto/ecdh.py +0 -0
  94. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/crypto/keys.py +0 -0
  95. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/crypto/schnorr.py +0 -0
  96. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/deploy/__init__.py +0 -0
  97. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/deploy/github_pages.py +0 -0
  98. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/mcp/__init__.py +0 -0
  99. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/mcp/server.py +0 -0
  100. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/__init__.py +0 -0
  101. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/auth.py +0 -0
  102. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/command.py +0 -0
  103. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/config.py +0 -0
  104. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/energy.py +0 -0
  105. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/sharing.py +0 -0
  106. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/user.py +0 -0
  107. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/models/vehicle.py +0 -0
  108. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/__init__.py +0 -0
  109. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/emitter.py +0 -0
  110. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/filters.py +0 -0
  111. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/openclaw/telemetry_store.py +0 -0
  112. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/output/__init__.py +0 -0
  113. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/output/formatter.py +0 -0
  114. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/output/json_output.py +0 -0
  115. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/output/rich_output.py +0 -0
  116. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/__init__.py +0 -0
  117. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/commands.py +0 -0
  118. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/encoder.py +0 -0
  119. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/metadata.py +0 -0
  120. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/payloads.py +0 -0
  121. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/protobuf/__init__.py +0 -0
  122. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/protobuf/messages.py +0 -0
  123. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/session.py +0 -0
  124. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/protocol/signer.py +0 -0
  125. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/py.typed +0 -0
  126. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/__init__.py +0 -0
  127. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/cache_sink.py +0 -0
  128. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/csv_sink.py +0 -0
  129. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/dashboard.py +0 -0
  130. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/decoder.py +0 -0
  131. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/fanout.py +0 -0
  132. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/fields.py +0 -0
  133. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/flatbuf.py +0 -0
  134. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/mapper.py +0 -0
  135. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/__init__.py +0 -0
  136. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_alert.proto +0 -0
  137. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_alert_pb2.py +0 -0
  138. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_alert_pb2.pyi +0 -0
  139. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_connectivity.proto +0 -0
  140. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_connectivity_pb2.py +0 -0
  141. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +0 -0
  142. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_data.proto +0 -0
  143. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_data_pb2.py +0 -0
  144. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_data_pb2.pyi +0 -0
  145. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_error.proto +0 -0
  146. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_error_pb2.py +0 -0
  147. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_error_pb2.pyi +0 -0
  148. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_metric.proto +0 -0
  149. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_metric_pb2.py +0 -0
  150. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/protos/vehicle_metric_pb2.pyi +0 -0
  151. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/server.py +0 -0
  152. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/setup.py +0 -0
  153. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/tailscale.py +0 -0
  154. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/telemetry/tui.py +0 -0
  155. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/triggers/__init__.py +0 -0
  156. {tescmd-0.4.0 → tescmd-0.5.0}/src/tescmd/triggers/manager.py +0 -0
  157. {tescmd-0.4.0 → tescmd-0.5.0}/tests/__init__.py +0 -0
  158. {tescmd-0.4.0 → tescmd-0.5.0}/tests/_internal/__init__.py +0 -0
  159. {tescmd-0.4.0 → tescmd-0.5.0}/tests/_internal/test_async_utils.py +0 -0
  160. {tescmd-0.4.0 → tescmd-0.5.0}/tests/_internal/test_vin.py +0 -0
  161. {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/__init__.py +0 -0
  162. {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_client.py +0 -0
  163. {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_command_api.py +0 -0
  164. {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_energy_api.py +0 -0
  165. {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_partner_api.py +0 -0
  166. {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_sharing_api.py +0 -0
  167. {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_signed_command.py +0 -0
  168. {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_user_api.py +0 -0
  169. {tescmd-0.4.0 → tescmd-0.5.0}/tests/api/test_vehicle_api.py +0 -0
  170. {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/__init__.py +0 -0
  171. {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_oauth.py +0 -0
  172. {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_oauth_extended.py +0 -0
  173. {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_server.py +0 -0
  174. {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_token_store.py +0 -0
  175. {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_token_store_fallback.py +0 -0
  176. {tescmd-0.4.0 → tescmd-0.5.0}/tests/auth/test_token_store_file.py +0 -0
  177. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cache/__init__.py +0 -0
  178. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cache/test_generic_cache.py +0 -0
  179. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cache/test_keys.py +0 -0
  180. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cache/test_response_cache.py +0 -0
  181. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/__init__.py +0 -0
  182. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/_helpers.py +0 -0
  183. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/conftest.py +0 -0
  184. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_auth.py +0 -0
  185. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_auth_exec.py +0 -0
  186. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_bugfixes.py +0 -0
  187. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_cache.py +0 -0
  188. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_cached_api_call.py +0 -0
  189. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_charge_exec.py +0 -0
  190. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_cli_integration.py +0 -0
  191. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_climate_exec.py +0 -0
  192. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_e2e_smoke.py +0 -0
  193. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_energy.py +0 -0
  194. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_energy_exec.py +0 -0
  195. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_error_handlers.py +0 -0
  196. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_key.py +0 -0
  197. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_key_enroll.py +0 -0
  198. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_key_unenroll.py +0 -0
  199. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_main_errors.py +0 -0
  200. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_media.py +0 -0
  201. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_media_exec.py +0 -0
  202. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_nav.py +0 -0
  203. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_nav_exec.py +0 -0
  204. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_partner.py +0 -0
  205. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_raw.py +0 -0
  206. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_raw_exec.py +0 -0
  207. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_security_exec.py +0 -0
  208. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_setup.py +0 -0
  209. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_setup_scope_check.py +0 -0
  210. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_sharing.py +0 -0
  211. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_sharing_exec.py +0 -0
  212. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_software.py +0 -0
  213. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_software_exec.py +0 -0
  214. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_status_exec.py +0 -0
  215. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_tier_enforcement.py +0 -0
  216. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_trunk_exec.py +0 -0
  217. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_user.py +0 -0
  218. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_user_exec.py +0 -0
  219. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_vcsec_guard.py +0 -0
  220. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_vehicle_exec.py +0 -0
  221. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_vehicle_power_exec.py +0 -0
  222. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_verbose.py +0 -0
  223. {tescmd-0.4.0 → tescmd-0.5.0}/tests/cli/test_wake_confirmation.py +0 -0
  224. {tescmd-0.4.0 → tescmd-0.5.0}/tests/conftest.py +0 -0
  225. {tescmd-0.4.0 → tescmd-0.5.0}/tests/crypto/__init__.py +0 -0
  226. {tescmd-0.4.0 → tescmd-0.5.0}/tests/crypto/test_ecdh.py +0 -0
  227. {tescmd-0.4.0 → tescmd-0.5.0}/tests/crypto/test_keys.py +0 -0
  228. {tescmd-0.4.0 → tescmd-0.5.0}/tests/crypto/test_schnorr.py +0 -0
  229. {tescmd-0.4.0 → tescmd-0.5.0}/tests/deploy/__init__.py +0 -0
  230. {tescmd-0.4.0 → tescmd-0.5.0}/tests/deploy/test_github_pages.py +0 -0
  231. {tescmd-0.4.0 → tescmd-0.5.0}/tests/deploy/test_tailscale_serve.py +0 -0
  232. {tescmd-0.4.0 → tescmd-0.5.0}/tests/integration/__init__.py +0 -0
  233. {tescmd-0.4.0 → tescmd-0.5.0}/tests/integration/test_serve.py +0 -0
  234. {tescmd-0.4.0 → tescmd-0.5.0}/tests/mcp/__init__.py +0 -0
  235. {tescmd-0.4.0 → tescmd-0.5.0}/tests/mcp/test_server.py +0 -0
  236. {tescmd-0.4.0 → tescmd-0.5.0}/tests/mcp/test_tools.py +0 -0
  237. {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/__init__.py +0 -0
  238. {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_auth.py +0 -0
  239. {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_config.py +0 -0
  240. {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_energy.py +0 -0
  241. {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_sharing.py +0 -0
  242. {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_user_models.py +0 -0
  243. {tescmd-0.4.0 → tescmd-0.5.0}/tests/models/test_vehicle.py +0 -0
  244. {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/__init__.py +0 -0
  245. {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_dispatcher.py +0 -0
  246. {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_emitter.py +0 -0
  247. {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_filters.py +0 -0
  248. {tescmd-0.4.0 → tescmd-0.5.0}/tests/openclaw/test_telemetry_store.py +0 -0
  249. {tescmd-0.4.0 → tescmd-0.5.0}/tests/output/__init__.py +0 -0
  250. {tescmd-0.4.0 → tescmd-0.5.0}/tests/output/test_formatter.py +0 -0
  251. {tescmd-0.4.0 → tescmd-0.5.0}/tests/output/test_json_output.py +0 -0
  252. {tescmd-0.4.0 → tescmd-0.5.0}/tests/output/test_rich_output.py +0 -0
  253. {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/__init__.py +0 -0
  254. {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/conftest.py +0 -0
  255. {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_boombox.py +0 -0
  256. {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_commands.py +0 -0
  257. {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_encoder.py +0 -0
  258. {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_metadata.py +0 -0
  259. {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_navigation.py +0 -0
  260. {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_session.py +0 -0
  261. {tescmd-0.4.0 → tescmd-0.5.0}/tests/protocol/test_signer.py +0 -0
  262. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/__init__.py +0 -0
  263. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/conftest.py +0 -0
  264. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_cache_sink.py +0 -0
  265. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_csv_sink.py +0 -0
  266. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_dashboard.py +0 -0
  267. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_decoder.py +0 -0
  268. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_fanout.py +0 -0
  269. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_fields.py +0 -0
  270. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_flatbuf.py +0 -0
  271. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_mapper.py +0 -0
  272. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_server.py +0 -0
  273. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_stream_cmd.py +0 -0
  274. {tescmd-0.4.0 → tescmd-0.5.0}/tests/telemetry/test_tailscale.py +0 -0
  275. {tescmd-0.4.0 → tescmd-0.5.0}/tests/triggers/__init__.py +0 -0
  276. {tescmd-0.4.0 → tescmd-0.5.0}/tests/triggers/test_manager.py +0 -0
  277. {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.4.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.4.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
  ]
@@ -1,3 +1,3 @@
1
1
  """tescmd — A Python CLI for querying and controlling Tesla vehicles via the Fleet API."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -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 that has the Tesla app installed.[/dim]"
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.tailscale_serve import fetch_tailscale_key_pem, get_key_url
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.github_pages import fetch_key_pem, get_key_url
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 # type: ignore[assignment]
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: Any # CommandDispatcher — avoids circular import
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 (e.g. ``"location"``, ``"climate"``)
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 Enum
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(str, Enum):
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
- # 1 connected lifecycle event + 1 data event = 2 sends
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
- # 1 connected lifecycle event + 2 data events = 3 sends
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
- assert gateway._ws.send.call_count == 1
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 test_first_frame_sends_connected_event(self, gateway: GatewayClient) -> None:
286
- """First frame should trigger a node.connected event before data events."""
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
- frame = _make_frame(data=[TelemetryDatum("Soc", 3, 72.0, "float")])
293
- await bridge.on_frame(frame)
290
+ assert await bridge.send_connected() is True
294
291
 
295
- # First send should be the connected event, second the data event
296
- assert gateway._ws.send.call_count == 2
297
- first_msg = json.loads(gateway._ws.send.call_args_list[0][0][0])
298
- assert first_msg["method"] == "req:agent"
299
- assert first_msg["params"]["event_type"] == "node.connected"
300
- assert first_msg["params"]["vin"] == "VIN1"
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 test_connected_event_sent_only_once(self, gateway: GatewayClient) -> None:
305
- """node.connected should only be sent on the first frame."""
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
- # 1 connected + 1 data = 2
314
- assert gateway._ws.send.call_count == 2
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
- # Only 1 more data event (no second connected) = 3
319
- assert gateway._ws.send.call_count == 3
316
+ # One more data event
317
+ assert gateway._ws.send.call_count == 2
320
318
 
321
319
  @pytest.mark.asyncio
322
- async def test_connected_event_not_sent_in_dry_run(self, gateway: GatewayClient) -> None:
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
- frame = _make_frame(data=[TelemetryDatum("Soc", 3, 72.0, "float")])
331
- await bridge.on_frame(frame)
328
+ # Dry-run is considered success (nothing to send)
329
+ assert await bridge.send_connected() is True
332
330
 
333
- # Dry run doesn't send anything via gateway
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 test_connected_event_skipped_when_disconnected(self, gateway: GatewayClient) -> None:
338
- """No connected event if gateway is disconnected."""
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
- # Patch out reconnect to keep things simple
346
- bridge._reconnect_at = float("inf")
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
- # Nothing sent (gateway is disconnected)
352
- assert gateway._ws.send.call_count == 0
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" in caps.reads
120
- assert "battery.get" in caps.reads
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) == 8
165
- assert len(cfg.capabilities.writes) == 21
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"