diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index a5024427f58b..3f7c4115a7bc 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -25,6 +25,11 @@ from tesla_fleet_api.exceptions import ( SubscriptionRequired, TeslaFleetError, WhitelistOperationAttemptingToAddExistingKey, + WhitelistOperationCouldNotStartLocalEntityAuth, + WhitelistOperationLocalEntityAuthFailedCancelled, + WhitelistOperationLocalEntityAuthFailedTimedOutWaitingForTap, + WhitelistOperationLocalEntityAuthFailedTimedOutWaitingForUIAck, + WhitelistOperationLocalEntityAuthFailedUIDenied, ) from tesla_fleet_api.tesla import EnergySiteRouter from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth @@ -231,6 +236,7 @@ class VehicleSubentryFlowHandler(ConfigSubentryFlow): self._vehicle: VehicleBluetooth | None = None self._pair_task: asyncio.Task[None] | None = None self._pair_error: dict[str, str] = {} + self._key_added = False async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -340,7 +346,10 @@ class VehicleSubentryFlowHandler(ConfigSubentryFlow): # The scan step owns the form; re-show it so a retry redoes scan and connect. return self.async_show_form( step_id="scan", - errors={"base": "cannot_connect"}, + # The key is already on the vehicle; say so rather than prompt a re-pair. + errors={ + "base": "key_unverified" if self._key_added else "cannot_connect" + }, description_placeholders={"vin": self._vin or ""}, ) if TYPE_CHECKING: @@ -399,6 +408,27 @@ class VehicleSubentryFlowHandler(ConfigSubentryFlow): return self.async_show_progress_done(next_step_id="instructions") except WhitelistOperationAttemptingToAddExistingKey as err: LOGGER.debug("Virtual key is already on the whitelist: %s", err) + except WhitelistOperationLocalEntityAuthFailedTimedOutWaitingForTap as err: + LOGGER.debug( + "No key card was tapped before the vehicle stopped waiting: %s", err + ) + self._pair_error = {"base": "tap_timeout"} + return self.async_show_progress_done(next_step_id="instructions") + except WhitelistOperationLocalEntityAuthFailedTimedOutWaitingForUIAck as err: + LOGGER.debug("Key was not confirmed on the vehicle touchscreen: %s", err) + self._pair_error = {"base": "confirm_timeout"} + return self.async_show_progress_done(next_step_id="instructions") + except ( + WhitelistOperationLocalEntityAuthFailedUIDenied, + WhitelistOperationLocalEntityAuthFailedCancelled, + ) as err: + LOGGER.debug("Key was declined on the vehicle touchscreen: %s", err) + self._pair_error = {"base": "pair_denied"} + return self.async_show_progress_done(next_step_id="instructions") + except WhitelistOperationCouldNotStartLocalEntityAuth as err: + LOGGER.debug("Vehicle could not start the key card request: %s", err) + self._pair_error = {"base": "auth_not_started"} + return self.async_show_progress_done(next_step_id="instructions") except TeslaFleetError as err: LOGGER.error("Bluetooth pairing was rejected: %s", err) self._pair_error = {"base": "pair_failed"} @@ -407,6 +437,7 @@ class VehicleSubentryFlowHandler(ConfigSubentryFlow): # async_remove() only runs if the flow is still tracked when this step raises. await self._async_disconnect() raise + self._key_added = True return self.async_show_progress_done(next_step_id="pair") async def _async_disconnect(self) -> None: diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 528a515747f4..7343d3a7f54a 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -100,21 +100,26 @@ }, "entry_type": "Local vehicle", "error": { + "auth_not_started": "The vehicle could not start the key card request. Wake the vehicle, then select Submit to try again.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "confirm_timeout": "The key was not confirmed on the vehicle's touchscreen in time. Select Submit, tap a key card, then confirm the prompt on the vehicle's touchscreen.", "device_not_found": "No matching Tesla vehicle was found nearby over Bluetooth. Make sure the vehicle is awake and within range, then try again.", + "key_unverified": "The key was added, but the vehicle's reply could not be verified. Select Submit to try again.", + "pair_denied": "The key was declined on the vehicle's touchscreen. Select Submit and tap a key card to try again.", "pair_failed": "The vehicle rejected the key. Make sure it is not in valet mode and does not already have the maximum number of keys, then try again.", - "timeout": "Timed out waiting for the vehicle to accept the key. Try again after approving the key on the vehicle's touchscreen." + "tap_timeout": "The key card was not tapped in time. Select Submit and tap a key card within 30 seconds.", + "timeout": "Timed out waiting for the vehicle to add the key. Select Submit and tap a key card on the center console card reader." }, "initiate_flow": { "user": "Add local vehicle" }, "progress": { - "pair": "Approve Home Assistant's virtual key on the vehicle's touchscreen. Waiting for the vehicle to accept the key." + "pair": "Tap a key card on the center console card reader now. The vehicle stops waiting after about 30 seconds." }, "step": { "instructions": { - "description": "Select **Submit**, then place your key card against the center console card reader of vehicle {vin} to approve Home Assistant's virtual key. This only needs to be done once.", - "title": "Approve the virtual key" + "description": "Vehicle {vin} does not have Home Assistant's Bluetooth key on its key list. This is expected the first time, and happens again if the key was removed from the vehicle. To add it, select **Submit**, then tap a key card on the center console card reader within about 30 seconds. The vehicle's touchscreen shows a prompt while it waits for the card.", + "title": "Add Home Assistant's key to the vehicle" }, "scan": { "description": "Home Assistant will look for vehicle {vin} over Bluetooth. Make sure the vehicle is within Bluetooth range of a Home Assistant Bluetooth adapter and has fewer than three phone keys in range, then continue.", diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index 8867cef0d8af..8cc8988a4530 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -29,9 +29,15 @@ from tesla_fleet_api.exceptions import ( InvalidToken, NotOnWhitelistFault, PrivateKeyError, + SessionInfoAuthenticationFault, SubscriptionRequired, TeslaFleetError, WhitelistOperationAttemptingToAddExistingKey, + WhitelistOperationCouldNotStartLocalEntityAuth, + WhitelistOperationLocalEntityAuthFailedCancelled, + WhitelistOperationLocalEntityAuthFailedTimedOutWaitingForTap, + WhitelistOperationLocalEntityAuthFailedTimedOutWaitingForUIAck, + WhitelistOperationLocalEntityAuthFailedUIDenied, ) from tesla_fleet_api.tesla import VehicleRouter from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth @@ -918,6 +924,61 @@ async def test_subentry_pairing_requires_key_approval(hass: HomeAssistant) -> No vehicle.pair.assert_awaited_once() +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subentry_handshake_fails_after_pairing(hass: HomeAssistant) -> None: + """A handshake failure after the key was added says so; retrying finishes without re-pairing.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle() + vehicle.handshakeVehicleSecurity = AsyncMock( + side_effect=[NotOnWhitelistFault(), SessionInfoAuthenticationFault(), None] + ) + release = asyncio.Event() + + async def _pair() -> None: + await release.wait() + + vehicle.pair = AsyncMock(side_effect=_pair) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "instructions" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + + release.set() + await hass.async_block_till_done() + result = await hass.config_entries.subentries.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scan" + assert result["errors"] == {"base": "key_unverified"} + assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)) == 1 + vehicle.pair.assert_awaited_once() + + @pytest.mark.usefixtures("enable_bluetooth") async def test_subentry_scan_connect_fails(hass: HomeAssistant) -> None: """The scan step re-shows the form with an error when BLE connect fails.""" @@ -953,9 +1014,26 @@ async def test_subentry_scan_connect_fails(hass: HomeAssistant) -> None: [ (BluetoothTimeout, "timeout"), (BluetoothTransportError, "cannot_connect"), + (WhitelistOperationLocalEntityAuthFailedTimedOutWaitingForTap, "tap_timeout"), + ( + WhitelistOperationLocalEntityAuthFailedTimedOutWaitingForUIAck, + "confirm_timeout", + ), + (WhitelistOperationLocalEntityAuthFailedUIDenied, "pair_denied"), + (WhitelistOperationLocalEntityAuthFailedCancelled, "pair_denied"), + (WhitelistOperationCouldNotStartLocalEntityAuth, "auth_not_started"), (TeslaFleetError, "pair_failed"), ], - ids=["timeout", "transport", "rejected"], + ids=[ + "timeout", + "transport", + "tap_timeout", + "ui_ack_timeout", + "denied", + "cancelled", + "auth_not_started", + "rejected", + ], ) @pytest.mark.usefixtures("enable_bluetooth") async def test_subentry_authorize_failure(