diff --git a/README.md b/README.md index 14eb8f3..96ff86e 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ logging.getLogger("tesla_fleet_api").setLevel(logging.DEBUG) ``` Command log lines use `transport=bluetooth`, `fleet`, `teslemetry`, or `tessie`. -Routers also emit `backend=` lines for each backend tried. See +Routers also emit `backend=` lines for each backend tried, except a local Powerwall in an `EnergySiteRouter`, which logs `transport=lan`. See [Bluetooth for Vehicles](docs/bluetooth_vehicles.md#troubleshooting-enable-debug-logging) for examples and the signed-command naming details. REST responses that are valid JSON but not objects, such as `null`, lists, or diff --git a/tesla_fleet_api/router/base.py b/tesla_fleet_api/router/base.py index 5bed55f..ad5728b 100644 --- a/tesla_fleet_api/router/base.py +++ b/tesla_fleet_api/router/base.py @@ -160,6 +160,10 @@ async def is_healthy(self) -> bool: return health return bool(await _maybe_await(health())) + def _backend_field(self, backend: Any) -> str: + """Return the ``key=value`` log field naming the backend.""" + return f"backend={type(backend).__name__}" + def _dispatch( self, name: str, @@ -193,9 +197,9 @@ async def _routed(*args: Any, **kwargs: Any) -> Any: # replaying it on the next one risks double-executing it. # Surface the ambiguity to the caller instead of failing over. LOGGER.debug( - "command=%s backend=%s result=unconfirmed error=%s: %s", + "command=%s %s result=unconfirmed error=%s: %s", name, - type(backend).__name__, + self._backend_field(backend), type(e).__name__, e, ) @@ -203,9 +207,9 @@ async def _routed(*args: Any, **kwargs: Any) -> Any: except (Exception, TeslaFleetError) as e: # noqa: BLE001 - any failure -> next backend last_exc = e LOGGER.debug( - "command=%s backend=%s result=error error=%s: %s", + "command=%s %s result=error error=%s: %s", name, - type(backend).__name__, + self._backend_field(backend), type(e).__name__, e, ) @@ -215,7 +219,7 @@ async def _routed(*args: Any, **kwargs: Any) -> Any: raise continue LOGGER.debug( - "command=%s backend=%s result=success", name, type(backend).__name__ + "command=%s %s result=success", name, self._backend_field(backend) ) if self._on_result is not None: await _maybe_await(self._on_result(None, backend, name)) diff --git a/tesla_fleet_api/router/energysite.py b/tesla_fleet_api/router/energysite.py index 436c9b6..a34b6e3 100644 --- a/tesla_fleet_api/router/energysite.py +++ b/tesla_fleet_api/router/energysite.py @@ -72,3 +72,9 @@ class EnergySiteRouter(Router[PrimaryT, SecondaryT]): router = EnergySiteRouter(local_energysite, teslemetry_energysite) await router.operation(...) # local first, cloud on failure """ + + def _backend_field(self, backend: Any) -> str: + """Log ``transport=lan`` for the local primary; cloud fallbacks keep the ``backend=`` field.""" + if backend is self.primary: + return "transport=lan" + return super()._backend_field(backend) diff --git a/tests/test_command_logging.py b/tests/test_command_logging.py index 575580d..c4e1048 100644 --- a/tests/test_command_logging.py +++ b/tests/test_command_logging.py @@ -17,6 +17,7 @@ from tesla_fleet_api.const import Method from tesla_fleet_api.exceptions import BluetoothTimeout, NotFound from tesla_fleet_api.router import Router +from tesla_fleet_api.router.energysite import EnergySiteRouter from tesla_fleet_api.tesla.fleet import TeslaFleetApi from tesla_fleet_api.teslemetry.teslemetry import Teslemetry from tesla_fleet_api.tessie.tessie import Tessie @@ -372,3 +373,30 @@ async def test_primary_failure_fails_over_and_logs_both_hops(self) -> None: self.assertIn("ConnectionError", joined) self.assertIn("backend=_FakeFallback", joined) self.assertIn("result=success", joined) + + +class _LocalSite: + """Duck-typed local energy site with a duck-typed cloud fallback.""" + + async def shared(self, value: int) -> str: + return f"local:{value}" + + +class EnergySiteRouterLocalTransportTests(IsolatedAsyncioTestCase): + async def test_local_backend_logs_transport_lan(self) -> None: + router = EnergySiteRouter(_LocalSite(), _FakeFallback()) + + with self.assertLogs(LOGGER_NAME, level="DEBUG") as captured: + await router.shared(1) + + self.assertIn("command=shared transport=lan result=success", "\n".join(captured.output)) + + async def test_non_primary_duck_typed_backend_keeps_backend_field(self) -> None: + router = EnergySiteRouter(_FakePrimary(fail=True), _LocalSite()) + + with self.assertLogs(LOGGER_NAME, level="DEBUG") as captured: + await router.shared(1) + + joined = "\n".join(captured.output) + self.assertIn("transport=lan result=error", joined) + self.assertIn("backend=_LocalSite result=success", joined)