From 7d4713a69f170316ff1a67af4d023c67800fb77e Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Fri, 20 Mar 2026 12:35:55 -0400 Subject: [PATCH 01/23] Add iOS Live Activity webhook handlers to mobile_app integration Add support for iOS Live Activities in the mobile_app integration: - Add `supports_live_activities`, `supports_live_activities_frequent_updates`, `live_activity_push_to_start_token`, and `live_activity_push_to_start_apns_environment` fields to SCHEMA_APP_DATA for explicit validation during device registration - Add `update_live_activity_token` webhook handler: stores per-activity APNs push tokens reported by the iOS companion app when a Live Activity is created locally via ActivityKit - Add `live_activity_dismissed` webhook handler: cleans up stored tokens when a Live Activity ends on the device - Both handlers fire bus events so automations can react to activity lifecycle - Add `supports_live_activities()` utility helper - Add 4 tests covering token storage, default environment, dismissal cleanup, and nonexistent tag dismissal for: home-assistant/mobile-apps-fcm-push#278 for: home-assistant/iOS#4444 Co-Authored-By: Claude Sonnet 4.6 --- .../components/mobile_app/__init__.py | 2 + homeassistant/components/mobile_app/const.py | 26 +++ homeassistant/components/mobile_app/util.py | 9 ++ .../components/mobile_app/webhook.py | 88 ++++++++++ tests/components/mobile_app/test_webhook.py | 150 ++++++++++++++++++ 5 files changed, 275 insertions(+) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 441022aaf6e741..cec42f83fc7518 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -42,6 +42,7 @@ DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, + DATA_LIVE_ACTIVITY_TOKENS, DATA_PENDING_UPDATES, DATA_PUSH_CHANNEL, DATA_STORE, @@ -81,6 +82,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []), DATA_DEVICES: {}, + DATA_LIVE_ACTIVITY_TOKENS: {}, DATA_PUSH_CHANNEL: {}, DATA_STORE: store, DATA_PENDING_UPDATES: {sensor_type: {} for sensor_type in SENSOR_TYPES}, diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index a4ed3ea598bd45..bb2cf373c8bf19 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -35,6 +35,23 @@ ATTR_PUSH_WEBSOCKET_CHANNEL = "push_websocket_channel" ATTR_PUSH_TOKEN = "push_token" ATTR_PUSH_URL = "push_url" + +ATTR_SUPPORTS_LIVE_ACTIVITIES = "supports_live_activities" +ATTR_SUPPORTS_LIVE_ACTIVITIES_FREQUENT_UPDATES = ( + "supports_live_activities_frequent_updates" +) +ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN = "live_activity_push_to_start_token" +ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT = ( + "live_activity_push_to_start_apns_environment" +) + +# Tag identifying a specific Live Activity instance — matches the `tag` field used by +# the iOS companion app's ActivityKit integration. +ATTR_LIVE_ACTIVITY_TAG = "tag" + +# In-memory store for per-device Live Activity push tokens, keyed by webhook_id → tag. +# Populated by update_live_activity_token and cleared by live_activity_dismissed webhooks. +DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens" ATTR_PUSH_RATE_LIMITS = "rateLimits" ATTR_PUSH_RATE_LIMITS_ERRORS = "errors" ATTR_PUSH_RATE_LIMITS_MAXIMUM = "maximum" @@ -92,6 +109,15 @@ # Set to True to indicate that this registration will connect via websocket channel # to receive push notifications. vol.Optional(ATTR_PUSH_WEBSOCKET_CHANNEL): cv.boolean, + # iOS Live Activities capability flags and push-to-start token (iOS 17.2+). + # push-to-start allows HA to remotely start a new Live Activity on the device + # without requiring one to already be running. + vol.Optional(ATTR_SUPPORTS_LIVE_ACTIVITIES): cv.boolean, + vol.Optional(ATTR_SUPPORTS_LIVE_ACTIVITIES_FREQUENT_UPDATES): cv.boolean, + vol.Optional(ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN): cv.string, + vol.Optional(ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT): vol.In( + ["sandbox", "production"] + ), }, extra=vol.ALLOW_EXTRA, ) diff --git a/homeassistant/components/mobile_app/util.py b/homeassistant/components/mobile_app/util.py index 3c52e858a39692..722f31911bf6ef 100644 --- a/homeassistant/components/mobile_app/util.py +++ b/homeassistant/components/mobile_app/util.py @@ -15,6 +15,7 @@ ATTR_PUSH_TOKEN, ATTR_PUSH_URL, ATTR_PUSH_WEBSOCKET_CHANNEL, + ATTR_SUPPORTS_LIVE_ACTIVITIES, CONF_CLOUDHOOK_URL, DATA_CONFIG_ENTRIES, DATA_DEVICES, @@ -49,6 +50,14 @@ def supports_push(hass: HomeAssistant, webhook_id: str) -> bool: ) or ATTR_PUSH_WEBSOCKET_CHANNEL in app_data +@callback +def supports_live_activities(hass: HomeAssistant, webhook_id: str) -> bool: + """Return if the device supports iOS Live Activities.""" + config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] + app_data = config_entry.data.get(ATTR_APP_DATA, {}) + return bool(app_data.get(ATTR_SUPPORTS_LIVE_ACTIVITIES)) + + @callback def get_notify_service(hass: HomeAssistant, webhook_id: str) -> str | None: """Return the notify service for this webhook ID.""" diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 232c4c50c6c336..bc1e6639cea832 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -67,10 +67,13 @@ ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, + ATTR_LIVE_ACTIVITY_TAG, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NO_LEGACY_ENCRYPTION, ATTR_OS_VERSION, + ATTR_PUSH_TOKEN, + ATTR_PUSH_URL, ATTR_SENSOR_ATTRIBUTES, ATTR_SENSOR_DEVICE_CLASS, ATTR_SENSOR_DISABLED, @@ -99,6 +102,7 @@ DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, + DATA_LIVE_ACTIVITY_TOKENS, DATA_PENDING_UPDATES, DOMAIN, ERR_ENCRYPTION_ALREADY_ENABLED, @@ -798,3 +802,87 @@ async def webhook_scan_tag( registration_context(config_entry.data), ) return empty_okay_response() + + +@WEBHOOK_COMMANDS.register("update_live_activity_token") +@validate_schema( + { + vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, + vol.Required(ATTR_PUSH_TOKEN): cv.string, + vol.Required(ATTR_PUSH_URL): cv.url, + vol.Optional("apns_environment", default="production"): vol.In( + ["sandbox", "production"] + ), + } +) +async def webhook_update_live_activity_token( + hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, Any] +) -> Response: + """Handle a Live Activity token update from the iOS companion app. + + When the iOS app creates a Live Activity locally, ActivityKit provides + a per-activity APNs push token. The app sends this token (along with + the relay server URL and APNs environment) so HA can later push updates + to that specific activity via the relay server's Live Activity endpoint. + """ + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + activity_tag = data[ATTR_LIVE_ACTIVITY_TAG] + + live_activity_tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] + live_activity_tokens.setdefault(webhook_id, {})[activity_tag] = { + ATTR_PUSH_TOKEN: data[ATTR_PUSH_TOKEN], + ATTR_PUSH_URL: data[ATTR_PUSH_URL], + "apns_environment": data["apns_environment"], + } + + device: dr.DeviceEntry = hass.data[DOMAIN][DATA_DEVICES][webhook_id] + hass.bus.async_fire( + f"{DOMAIN}_live_activity_token_updated", + { + ATTR_LIVE_ACTIVITY_TAG: activity_tag, + "device_id": device.id, + "webhook_id": webhook_id, + }, + context=registration_context(config_entry.data), + ) + + return empty_okay_response() + + +@WEBHOOK_COMMANDS.register("live_activity_dismissed") +@validate_schema( + { + vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, + } +) +async def webhook_live_activity_dismissed( + hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, str] +) -> Response: + """Handle a Live Activity dismissal from the iOS companion app. + + When a Live Activity ends on the device (user dismissal, expiration, + or an explicit end event), the app notifies HA so the stored push + token for that activity can be cleaned up. + """ + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + activity_tag = data[ATTR_LIVE_ACTIVITY_TAG] + + live_activity_tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] + if webhook_id in live_activity_tokens: + live_activity_tokens[webhook_id].pop(activity_tag, None) + # Clean up the device key if no activities remain. + if not live_activity_tokens[webhook_id]: + del live_activity_tokens[webhook_id] + + device: dr.DeviceEntry = hass.data[DOMAIN][DATA_DEVICES][webhook_id] + hass.bus.async_fire( + f"{DOMAIN}_live_activity_dismissed", + { + ATTR_LIVE_ACTIVITY_TAG: activity_tag, + "device_id": device.id, + "webhook_id": webhook_id, + }, + context=registration_context(config_entry.data), + ) + + return empty_okay_response() diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index b7a247bc9736d5..2082ef00f63207 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1303,3 +1303,153 @@ async def test_sending_sensor_state( state = hass.states.get("sensor.test_1_battery_health") assert state is not None assert state.state == "okay-ish" + + +async def test_webhook_update_live_activity_token( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that we can store a Live Activity push token.""" + device = device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")}) + assert device is not None + + events = async_capture_events(hass, f"{DOMAIN}_live_activity_token_updated") + + webhook_id = create_registrations[1]["webhook_id"] + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "update_live_activity_token", + "data": { + "tag": "washer_cycle", + "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "push_url": "http://localhost/mock-push/iOS/liveActivity/v1", + "apns_environment": "sandbox", + }, + }, + ) + + assert resp.status == HTTPStatus.OK + result = await resp.json() + assert result == {} + + # Verify token was stored in hass.data + tokens = hass.data[DOMAIN]["live_activity_tokens"] + assert webhook_id in tokens + assert tokens[webhook_id]["washer_cycle"]["push_token"] == ( + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + ) + assert tokens[webhook_id]["washer_cycle"]["push_url"] == ( + "http://localhost/mock-push/iOS/liveActivity/v1" + ) + assert tokens[webhook_id]["washer_cycle"]["apns_environment"] == "sandbox" + + # Verify event was fired + assert len(events) == 1 + assert events[0].data["tag"] == "washer_cycle" + assert events[0].data["device_id"] == device.id + assert events[0].data["webhook_id"] == webhook_id + + +async def test_webhook_update_live_activity_token_defaults_production( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that apns_environment defaults to production.""" + webhook_id = create_registrations[1]["webhook_id"] + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "update_live_activity_token", + "data": { + "tag": "ev_charge", + "push_token": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "push_url": "http://localhost/mock-push/iOS/liveActivity/v1", + }, + }, + ) + + assert resp.status == HTTPStatus.OK + + tokens = hass.data[DOMAIN]["live_activity_tokens"] + assert tokens[webhook_id]["ev_charge"]["apns_environment"] == "production" + + +async def test_webhook_live_activity_dismissed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that we can dismiss a Live Activity and clean up its token.""" + device = device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")}) + assert device is not None + + webhook_id = create_registrations[1]["webhook_id"] + + # First register a token + await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "update_live_activity_token", + "data": { + "tag": "washer_cycle", + "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "push_url": "http://localhost/mock-push/iOS/liveActivity/v1", + }, + }, + ) + + # Verify token is stored + tokens = hass.data[DOMAIN]["live_activity_tokens"] + assert webhook_id in tokens + assert "washer_cycle" in tokens[webhook_id] + + # Now dismiss it + events = async_capture_events(hass, f"{DOMAIN}_live_activity_dismissed") + + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "live_activity_dismissed", + "data": { + "tag": "washer_cycle", + }, + }, + ) + + assert resp.status == HTTPStatus.OK + result = await resp.json() + assert result == {} + + # Verify token was removed — webhook_id key also cleaned up since no activities remain + assert webhook_id not in tokens + + # Verify event was fired + assert len(events) == 1 + assert events[0].data["tag"] == "washer_cycle" + assert events[0].data["device_id"] == device.id + + +async def test_webhook_live_activity_dismissed_nonexistent_tag( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that dismissing a nonexistent tag does not error.""" + webhook_id = create_registrations[1]["webhook_id"] + + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "live_activity_dismissed", + "data": { + "tag": "nonexistent_activity", + }, + }, + ) + + assert resp.status == HTTPStatus.OK From e377da7105674370801046adeaebcdfb8e4da506 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Fri, 20 Mar 2026 12:40:49 -0400 Subject: [PATCH 02/23] Use constants for event names and add EventOrigin.remote - Define EVENT_LIVE_ACTIVITY_TOKEN_UPDATED and EVENT_LIVE_ACTIVITY_DISMISSED constants in const.py instead of inline f-strings - Add ATTR_APNS_ENVIRONMENT constant for schema and data access - Add EventOrigin.remote to async_fire calls, matching webhook_fire_event pattern - Use DATA_LIVE_ACTIVITY_TOKENS constant in tests instead of string literals - Import event constants in tests for consistency Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/const.py | 4 ++++ .../components/mobile_app/webhook.py | 13 +++++++++---- tests/components/mobile_app/test_webhook.py | 19 +++++++++++++------ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index bb2cf373c8bf19..aa51dbaed6a58f 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -48,10 +48,14 @@ # Tag identifying a specific Live Activity instance — matches the `tag` field used by # the iOS companion app's ActivityKit integration. ATTR_LIVE_ACTIVITY_TAG = "tag" +ATTR_APNS_ENVIRONMENT = "apns_environment" # In-memory store for per-device Live Activity push tokens, keyed by webhook_id → tag. # Populated by update_live_activity_token and cleared by live_activity_dismissed webhooks. DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens" + +EVENT_LIVE_ACTIVITY_TOKEN_UPDATED = f"{DOMAIN}_live_activity_token_updated" +EVENT_LIVE_ACTIVITY_DISMISSED = f"{DOMAIN}_live_activity_dismissed" ATTR_PUSH_RATE_LIMITS = "rateLimits" ATTR_PUSH_RATE_LIMITS_ERRORS = "errors" ATTR_PUSH_RATE_LIMITS_MAXIMUM = "maximum" diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index bc1e6639cea832..009c52bb544d12 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -60,6 +60,7 @@ from .const import ( ATTR_ALTITUDE, + ATTR_APNS_ENVIRONMENT, ATTR_APP_DATA, ATTR_APP_VERSION, ATTR_CAMERA_ENTITY_ID, @@ -109,6 +110,8 @@ ERR_ENCRYPTION_REQUIRED, ERR_INVALID_FORMAT, ERR_SENSOR_NOT_REGISTERED, + EVENT_LIVE_ACTIVITY_DISMISSED, + EVENT_LIVE_ACTIVITY_TOKEN_UPDATED, SCHEMA_APP_DATA, SENSOR_TYPES, SIGNAL_LOCATION_UPDATE, @@ -810,7 +813,7 @@ async def webhook_scan_tag( vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, vol.Required(ATTR_PUSH_TOKEN): cv.string, vol.Required(ATTR_PUSH_URL): cv.url, - vol.Optional("apns_environment", default="production"): vol.In( + vol.Optional(ATTR_APNS_ENVIRONMENT, default="production"): vol.In( ["sandbox", "production"] ), } @@ -832,17 +835,18 @@ async def webhook_update_live_activity_token( live_activity_tokens.setdefault(webhook_id, {})[activity_tag] = { ATTR_PUSH_TOKEN: data[ATTR_PUSH_TOKEN], ATTR_PUSH_URL: data[ATTR_PUSH_URL], - "apns_environment": data["apns_environment"], + ATTR_APNS_ENVIRONMENT: data[ATTR_APNS_ENVIRONMENT], } device: dr.DeviceEntry = hass.data[DOMAIN][DATA_DEVICES][webhook_id] hass.bus.async_fire( - f"{DOMAIN}_live_activity_token_updated", + EVENT_LIVE_ACTIVITY_TOKEN_UPDATED, { ATTR_LIVE_ACTIVITY_TAG: activity_tag, "device_id": device.id, "webhook_id": webhook_id, }, + EventOrigin.remote, context=registration_context(config_entry.data), ) @@ -876,12 +880,13 @@ async def webhook_live_activity_dismissed( device: dr.DeviceEntry = hass.data[DOMAIN][DATA_DEVICES][webhook_id] hass.bus.async_fire( - f"{DOMAIN}_live_activity_dismissed", + EVENT_LIVE_ACTIVITY_DISMISSED, { ATTR_LIVE_ACTIVITY_TAG: activity_tag, "device_id": device.id, "webhook_id": webhook_id, }, + EventOrigin.remote, context=registration_context(config_entry.data), ) diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 2082ef00f63207..8eee9815ef2417 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -13,7 +13,14 @@ import pytest from homeassistant.components.camera import CameraEntityFeature -from homeassistant.components.mobile_app.const import CONF_SECRET, DATA_DEVICES, DOMAIN +from homeassistant.components.mobile_app.const import ( + CONF_SECRET, + DATA_DEVICES, + DATA_LIVE_ACTIVITY_TOKENS, + DOMAIN, + EVENT_LIVE_ACTIVITY_DISMISSED, + EVENT_LIVE_ACTIVITY_TOKEN_UPDATED, +) from homeassistant.components.tag import EVENT_TAG_SCANNED from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.const import ( @@ -1315,7 +1322,7 @@ async def test_webhook_update_live_activity_token( device = device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")}) assert device is not None - events = async_capture_events(hass, f"{DOMAIN}_live_activity_token_updated") + events = async_capture_events(hass, EVENT_LIVE_ACTIVITY_TOKEN_UPDATED) webhook_id = create_registrations[1]["webhook_id"] resp = await webhook_client.post( @@ -1336,7 +1343,7 @@ async def test_webhook_update_live_activity_token( assert result == {} # Verify token was stored in hass.data - tokens = hass.data[DOMAIN]["live_activity_tokens"] + tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] assert webhook_id in tokens assert tokens[webhook_id]["washer_cycle"]["push_token"] == ( "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" @@ -1374,7 +1381,7 @@ async def test_webhook_update_live_activity_token_defaults_production( assert resp.status == HTTPStatus.OK - tokens = hass.data[DOMAIN]["live_activity_tokens"] + tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] assert tokens[webhook_id]["ev_charge"]["apns_environment"] == "production" @@ -1404,12 +1411,12 @@ async def test_webhook_live_activity_dismissed( ) # Verify token is stored - tokens = hass.data[DOMAIN]["live_activity_tokens"] + tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] assert webhook_id in tokens assert "washer_cycle" in tokens[webhook_id] # Now dismiss it - events = async_capture_events(hass, f"{DOMAIN}_live_activity_dismissed") + events = async_capture_events(hass, EVENT_LIVE_ACTIVITY_DISMISSED) resp = await webhook_client.post( f"/api/webhook/{webhook_id}", From 14a6987ec5d98180faf48825740bf7dde44cbae4 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Fri, 20 Mar 2026 13:04:06 -0400 Subject: [PATCH 03/23] Address Copilot review: Inclusive validation and token cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make push-to-start token and environment vol.Inclusive so they must be provided together — a token without an environment is ambiguous since sandbox tokens are rejected by the production APNs endpoint - Clean up DATA_LIVE_ACTIVITY_TOKENS for the webhook_id in async_unload_entry to prevent stale tokens accumulating in memory when devices are removed or re-added Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/__init__.py | 1 + homeassistant/components/mobile_app/const.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index cec42f83fc7518..ee9d8ea465fbe8 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -233,6 +233,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: webhook_unregister(hass, webhook_id) del hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] del hass.data[DOMAIN][DATA_DEVICES][webhook_id] + hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS].pop(webhook_id, None) await hass_notify.async_reload(hass, DOMAIN) return True diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index aa51dbaed6a58f..ab4f742973c2d3 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -118,10 +118,15 @@ # without requiring one to already be running. vol.Optional(ATTR_SUPPORTS_LIVE_ACTIVITIES): cv.boolean, vol.Optional(ATTR_SUPPORTS_LIVE_ACTIVITIES_FREQUENT_UPDATES): cv.boolean, - vol.Optional(ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN): cv.string, - vol.Optional(ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT): vol.In( - ["sandbox", "production"] - ), + # push-to-start token and environment must be provided together — a token + # without an environment is ambiguous (sandbox tokens fail on production). + vol.Inclusive( + ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, "live_activity_push_to_start" + ): cv.string, + vol.Inclusive( + ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT, + "live_activity_push_to_start", + ): vol.In(["sandbox", "production"]), }, extra=vol.ALLOW_EXTRA, ) From 3d7ea81b219e79551853befafa561d8e074a4e8a Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Fri, 20 Mar 2026 13:27:51 -0400 Subject: [PATCH 04/23] Wire up notify.py to route Live Activity pushes through APNs relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a notification contains live_activity: true and a tag, the notify service now routes it through the dedicated APNs relay endpoint instead of FCM. This completes the direct APNs delivery path: 1. Per-activity token — if the iOS app has registered a push token for the given tag (via update_live_activity_token webhook), use that token and its stored push_url to deliver directly to the running activity. 2. Push-to-start fallback — if no per-activity token exists but the device has a push-to-start token in app_data (iOS 17.2+), use that to start a new activity remotely without the app being open. 3. Normal FCM — if live_activity is not set, or no tag is provided, the notification flows through the existing FCM path unchanged. The apns_environment (sandbox/production) is included in registration_info so the relay server can route to the correct APNs endpoint. Adds 4 tests: stored token routing, push-to-start fallback, no-tag fallthrough, and normal notification ignoring stored tokens. Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/const.py | 5 +- homeassistant/components/mobile_app/notify.py | 70 +++++++- tests/components/mobile_app/test_notify.py | 150 +++++++++++++++++- 3 files changed, 216 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index ab4f742973c2d3..8f1556c3671a58 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -36,10 +36,7 @@ ATTR_PUSH_TOKEN = "push_token" ATTR_PUSH_URL = "push_url" -ATTR_SUPPORTS_LIVE_ACTIVITIES = "supports_live_activities" -ATTR_SUPPORTS_LIVE_ACTIVITIES_FREQUENT_UPDATES = ( - "supports_live_activities_frequent_updates" -) +ATTR_LIVE_UPDATE = "live_update" ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN = "live_activity_push_to_start_token" ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT = ( "live_activity_push_to_start_apns_environment" diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index e508212c80b28e..f06b72a926e0ca 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncGenerator from functools import partial from http import HTTPStatus import logging @@ -34,7 +35,12 @@ ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_VERSION, + ATTR_APNS_ENVIRONMENT, ATTR_DEVICE_NAME, + ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT, + ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, + ATTR_LIVE_ACTIVITY_TAG, + ATTR_LIVE_UPDATE, ATTR_OS_VERSION, ATTR_PUSH_RATE_LIMITS, ATTR_PUSH_RATE_LIMITS_ERRORS, @@ -45,6 +51,7 @@ ATTR_PUSH_URL, ATTR_WEBHOOK_ID, DATA_CONFIG_ENTRIES, + DATA_LIVE_ACTIVITY_TOKENS, DATA_NOTIFY, DATA_PUSH_CHANNEL, DOMAIN, @@ -211,12 +218,55 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: f"Device(s) with webhook id(s) {', '.join(failed_targets)} not connected to local push notifications" ) + def _get_live_activity_token( + self, entry: ConfigEntry, data: dict[str, Any] + ) -> dict[str, str] | None: + """Return Live Activity token info if this notification targets one. + + Checks whether the notification payload contains live_update: true and a + tag. If a per-activity APNs token is stored for that tag it is returned. + Otherwise, if the device has a push-to-start token, that is returned so + the relay server can start a new activity remotely. + + Returns None if this is a normal notification (not a Live Activity). + """ + notification_data = data.get(ATTR_DATA) or {} + if not notification_data.get(ATTR_LIVE_UPDATE): + return None + + tag = notification_data.get(ATTR_LIVE_ACTIVITY_TAG) + if not tag: + return None + + # Per-activity token — the activity is already running on the device. + webhook_id = entry.data[ATTR_WEBHOOK_ID] + live_activity_tokens = self.hass.data[DOMAIN].get(DATA_LIVE_ACTIVITY_TOKENS, {}) + device_tokens = live_activity_tokens.get(webhook_id, {}) + if tag in device_tokens: + return {ATTR_PUSH_TOKEN: device_tokens[tag]} + + # Push-to-start token — start a new activity remotely (iOS 17.2+). + app_data = entry.data[ATTR_APP_DATA] + if token := app_data.get(ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN): + result: dict[str, str] = {ATTR_PUSH_TOKEN: token} + if env := app_data.get(ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT): + result[ATTR_APNS_ENVIRONMENT] = env + return result + + return None + async def _async_send_remote_message_target( self, entry: ConfigEntry, data: dict[str, Any] - ): + ) -> None: """Send a message to a target.""" + live_activity_info = self._get_live_activity_token(entry, data) try: - await _send_message(async_get_clientsession(self.hass), entry, data) + await _send_message( + async_get_clientsession(self.hass), + entry, + data, + live_activity_info=live_activity_info, + ) except HomeAssistantError as e: if e.translation_key == "rate_limit_exceeded_sending_notification": _LOGGER.warning(str(e)) @@ -225,7 +275,11 @@ async def _async_send_remote_message_target( async def _send_message( - session: ClientSession, entry: ConfigEntry, data: dict[str, Any] + session: ClientSession, + entry: ConfigEntry, + data: dict[str, Any], + *, + live_activity_info: dict[str, str] | None = None, ) -> None: """Shared internal helper to send messages via cloud push notification services.""" reg_info = { @@ -235,6 +289,14 @@ async def _send_message( } if ATTR_OS_VERSION in entry.data: reg_info[ATTR_OS_VERSION] = entry.data[ATTR_OS_VERSION] + if live_activity_info and ATTR_APNS_ENVIRONMENT in live_activity_info: + reg_info[ATTR_APNS_ENVIRONMENT] = live_activity_info[ATTR_APNS_ENVIRONMENT] + + push_token = ( + live_activity_info[ATTR_PUSH_TOKEN] + if live_activity_info + else entry.data[ATTR_APP_DATA][ATTR_PUSH_TOKEN] + ) try: async with asyncio.timeout(10): @@ -242,7 +304,7 @@ async def _send_message( entry.data[ATTR_APP_DATA][ATTR_PUSH_URL], json={ **data, - ATTR_PUSH_TOKEN: entry.data[ATTR_APP_DATA][ATTR_PUSH_TOKEN], + ATTR_PUSH_TOKEN: push_token, "registration_info": reg_info, }, ) diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 405be4ef0e9593..dc8094f2f79f86 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -10,7 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.mobile_app.const import DOMAIN +from homeassistant.components.mobile_app.const import DATA_LIVE_ACTIVITY_TOKENS, DOMAIN from homeassistant.components.notify import ( ATTR_MESSAGE, ATTR_TITLE, @@ -835,3 +835,151 @@ async def test_send_message_local_push_exception(hass: HomeAssistant) -> None: assert err.value.translation_placeholders == { "device_name": "websocket push test entry" } + + +async def test_notify_live_activity_uses_stored_token( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver +) -> None: + """Test that live_update notifications route through a stored per-activity APNs token.""" + push_url = "https://mobile-push.home-assistant.dev/push" + + # Simulate the iOS app having registered a per-activity token via webhook. + hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]["mock-webhook_id"] = { + "washer_cycle": "LIVE_ACTIVITY_TOKEN_HEX" + } + + await hass.services.async_call( + "notify", + "mobile_app_test", + { + "message": "45 minutes remaining", + "target": ["mock-webhook_id"], + "data": {"live_update": True, "tag": "washer_cycle", "progress": 2700}, + }, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 + call_json = aioclient_mock.mock_calls[0][2] + # Should use the stored Live Activity token, not the FCM token. + assert call_json["push_token"] == "LIVE_ACTIVITY_TOKEN_HEX" + assert call_json["data"]["live_update"] is True + assert call_json["data"]["tag"] == "washer_cycle" + + +async def test_notify_live_activity_falls_back_to_push_to_start( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_admin_user: MockUser, +) -> None: + """Test that live_update without a stored token falls back to the push-to-start token.""" + push_url = "https://mobile-push.home-assistant.dev/push" + now = datetime.now() + timedelta(hours=24) + iso_time = now.strftime("%Y-%m-%dT%H:%M:%SZ") + + aioclient_mock.post( + push_url, + json={ + "rateLimits": { + "successful": 1, + "errors": 0, + "maximum": 150, + "resetsAt": iso_time, + } + }, + ) + + entry = MockConfigEntry( + data={ + "app_data": { + "push_token": "FCM_TOKEN", + "push_url": push_url, + "live_activity_push_to_start_token": "PUSH_TO_START_HEX_TOKEN", + "live_activity_push_to_start_apns_environment": "production", + }, + "app_id": "io.robbie.HomeAssistant", + "app_name": "Home Assistant", + "app_version": "2024.1", + "device_id": "ios-device-1", + "device_name": "iPhone", + "manufacturer": "Apple", + "model": "iPhone 15", + "os_name": "iOS", + "os_version": "17.2", + "supports_encryption": False, + "user_id": hass_admin_user.id, + "webhook_id": "ios-webhook-1", + }, + domain=DOMAIN, + source="registration", + title="iPhone entry", + version=1, + ) + entry.add_to_hass(hass) + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + await hass.services.async_call( + "notify", + "mobile_app_iphone", + { + "message": "Laundry started", + "target": ["ios-webhook-1"], + "data": {"live_update": True, "tag": "laundry"}, + }, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 + call_json = aioclient_mock.mock_calls[0][2] + # Should use push-to-start token since no per-activity token is stored. + assert call_json["push_token"] == "PUSH_TO_START_HEX_TOKEN" + assert call_json["registration_info"]["apns_environment"] == "production" + + +async def test_notify_live_activity_without_tag_uses_fcm( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver +) -> None: + """Test that live_update without a tag falls through to normal FCM push.""" + await hass.services.async_call( + "notify", + "mobile_app_test", + { + "message": "No tag here", + "target": ["mock-webhook_id"], + "data": {"live_update": True}, + }, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 + call_json = aioclient_mock.mock_calls[0][2] + # Should use normal FCM token since there is no tag. + assert call_json["push_token"] == "PUSH_TOKEN" + assert "apns_environment" not in call_json["registration_info"] + + +async def test_notify_normal_notification_ignores_live_activity_tokens( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver +) -> None: + """Test that normal notifications don't route through live activity tokens.""" + # Store a live activity token — it should be ignored for non-live-activity pushes. + hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]["mock-webhook_id"] = { + "some_tag": "SHOULD_NOT_USE_THIS" + } + + await hass.services.async_call( + "notify", + "mobile_app_test", + { + "message": "Normal notification", + "target": ["mock-webhook_id"], + "data": {"tag": "some_tag"}, + }, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 + call_json = aioclient_mock.mock_calls[0][2] + # Should use normal FCM token — live_update flag not set. + assert call_json["push_token"] == "PUSH_TOKEN" From 3f6346db5c0c1c3645043d9d245eb65e22102e5f Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Fri, 20 Mar 2026 23:21:55 -0400 Subject: [PATCH 05/23] =?UTF-8?q?Simplify=20Live=20Activity=20routing=20?= =?UTF-8?q?=E2=80=94=20use=20FCM=20native=20liveActivityToken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FCM v1 API natively supports Live Activities via apns.liveActivityToken. This simplifies the core integration: - notify.py: instead of routing to a separate relay URL, sends both the FCM token (push_token) and Live Activity APNs token (live_activity_token) to the SAME relay endpoint. The relay server places it in the FCM message's apns.liveActivityToken field, and FCM handles APNs delivery. - webhook.py: update_live_activity_token schema simplified — removed push_url and apns_environment (FCM handles routing automatically) - const.py: removed ATTR_APNS_ENVIRONMENT (no longer needed) - Tests updated to match simplified token storage and routing Co-Authored-By: Claude Opus 4.6 (1M context) --- homeassistant/components/mobile_app/const.py | 1 - homeassistant/components/mobile_app/notify.py | 53 +++++++++---------- .../components/mobile_app/webhook.py | 15 ++---- tests/components/mobile_app/test_notify.py | 18 +++---- tests/components/mobile_app/test_webhook.py | 17 +++--- 5 files changed, 44 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 8f1556c3671a58..f7be419fb8752e 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -45,7 +45,6 @@ # Tag identifying a specific Live Activity instance — matches the `tag` field used by # the iOS companion app's ActivityKit integration. ATTR_LIVE_ACTIVITY_TAG = "tag" -ATTR_APNS_ENVIRONMENT = "apns_environment" # In-memory store for per-device Live Activity push tokens, keyed by webhook_id → tag. # Populated by update_live_activity_token and cleared by live_activity_dismissed webhooks. diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index f06b72a926e0ca..8dd7755a99352d 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -35,9 +35,7 @@ ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_VERSION, - ATTR_APNS_ENVIRONMENT, ATTR_DEVICE_NAME, - ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT, ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, ATTR_LIVE_ACTIVITY_TAG, ATTR_LIVE_UPDATE, @@ -220,13 +218,17 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: def _get_live_activity_token( self, entry: ConfigEntry, data: dict[str, Any] - ) -> dict[str, str] | None: - """Return Live Activity token info if this notification targets one. + ) -> str | None: + """Return the Live Activity APNs token if this notification targets one. - Checks whether the notification payload contains live_update: true and a - tag. If a per-activity APNs token is stored for that tag it is returned. - Otherwise, if the device has a push-to-start token, that is returned so - the relay server can start a new activity remotely. + Checks whether the payload contains live_update: true and a tag. If a + per-activity APNs token is stored for that tag it is returned. Otherwise, + if the device has a push-to-start token, that is returned so the relay + server can start a new activity remotely. + + The token is sent alongside the regular FCM push_token as live_activity_token. + The relay places it in the FCM payload's apns.liveActivityToken field, and FCM + handles apns-push-type: liveactivity and APNs routing automatically. Returns None if this is a normal notification (not a Live Activity). """ @@ -243,15 +245,12 @@ def _get_live_activity_token( live_activity_tokens = self.hass.data[DOMAIN].get(DATA_LIVE_ACTIVITY_TOKENS, {}) device_tokens = live_activity_tokens.get(webhook_id, {}) if tag in device_tokens: - return {ATTR_PUSH_TOKEN: device_tokens[tag]} + return device_tokens[tag] # Push-to-start token — start a new activity remotely (iOS 17.2+). app_data = entry.data[ATTR_APP_DATA] if token := app_data.get(ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN): - result: dict[str, str] = {ATTR_PUSH_TOKEN: token} - if env := app_data.get(ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT): - result[ATTR_APNS_ENVIRONMENT] = env - return result + return token return None @@ -259,13 +258,12 @@ async def _async_send_remote_message_target( self, entry: ConfigEntry, data: dict[str, Any] ) -> None: """Send a message to a target.""" - live_activity_info = self._get_live_activity_token(entry, data) try: await _send_message( async_get_clientsession(self.hass), entry, data, - live_activity_info=live_activity_info, + live_activity_token=self._get_live_activity_token(entry, data), ) except HomeAssistantError as e: if e.translation_key == "rate_limit_exceeded_sending_notification": @@ -279,7 +277,7 @@ async def _send_message( entry: ConfigEntry, data: dict[str, Any], *, - live_activity_info: dict[str, str] | None = None, + live_activity_token: str | None = None, ) -> None: """Shared internal helper to send messages via cloud push notification services.""" reg_info = { @@ -289,24 +287,23 @@ async def _send_message( } if ATTR_OS_VERSION in entry.data: reg_info[ATTR_OS_VERSION] = entry.data[ATTR_OS_VERSION] - if live_activity_info and ATTR_APNS_ENVIRONMENT in live_activity_info: - reg_info[ATTR_APNS_ENVIRONMENT] = live_activity_info[ATTR_APNS_ENVIRONMENT] - push_token = ( - live_activity_info[ATTR_PUSH_TOKEN] - if live_activity_info - else entry.data[ATTR_APP_DATA][ATTR_PUSH_TOKEN] - ) + payload: dict[str, Any] = { + **data, + ATTR_PUSH_TOKEN: entry.data[ATTR_APP_DATA][ATTR_PUSH_TOKEN], + "registration_info": reg_info, + } + # If this is a Live Activity notification, include the APNs token so the relay + # server can set apns.liveActivityToken in the FCM payload. FCM then handles + # apns-push-type: liveactivity and APNs routing automatically. + if live_activity_token: + payload["live_activity_token"] = live_activity_token try: async with asyncio.timeout(10): response = await session.post( entry.data[ATTR_APP_DATA][ATTR_PUSH_URL], - json={ - **data, - ATTR_PUSH_TOKEN: push_token, - "registration_info": reg_info, - }, + json=payload, ) result: dict[str, Any] = await response.json() diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 009c52bb544d12..9d8e55c1dafc4c 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -60,7 +60,6 @@ from .const import ( ATTR_ALTITUDE, - ATTR_APNS_ENVIRONMENT, ATTR_APP_DATA, ATTR_APP_VERSION, ATTR_CAMERA_ENTITY_ID, @@ -74,7 +73,6 @@ ATTR_NO_LEGACY_ENCRYPTION, ATTR_OS_VERSION, ATTR_PUSH_TOKEN, - ATTR_PUSH_URL, ATTR_SENSOR_ATTRIBUTES, ATTR_SENSOR_DEVICE_CLASS, ATTR_SENSOR_DISABLED, @@ -812,10 +810,6 @@ async def webhook_scan_tag( { vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, vol.Required(ATTR_PUSH_TOKEN): cv.string, - vol.Required(ATTR_PUSH_URL): cv.url, - vol.Optional(ATTR_APNS_ENVIRONMENT, default="production"): vol.In( - ["sandbox", "production"] - ), } ) async def webhook_update_live_activity_token( @@ -824,9 +818,10 @@ async def webhook_update_live_activity_token( """Handle a Live Activity token update from the iOS companion app. When the iOS app creates a Live Activity locally, ActivityKit provides - a per-activity APNs push token. The app sends this token (along with - the relay server URL and APNs environment) so HA can later push updates - to that specific activity via the relay server's Live Activity endpoint. + a per-activity APNs push token. The app sends this token so HA can + later include it as live_activity_token in the push relay request. + The relay server places it in the FCM message's apns.liveActivityToken + field, and FCM handles APNs delivery automatically. """ webhook_id = config_entry.data[CONF_WEBHOOK_ID] activity_tag = data[ATTR_LIVE_ACTIVITY_TAG] @@ -834,8 +829,6 @@ async def webhook_update_live_activity_token( live_activity_tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] live_activity_tokens.setdefault(webhook_id, {})[activity_tag] = { ATTR_PUSH_TOKEN: data[ATTR_PUSH_TOKEN], - ATTR_PUSH_URL: data[ATTR_PUSH_URL], - ATTR_APNS_ENVIRONMENT: data[ATTR_APNS_ENVIRONMENT], } device: dr.DeviceEntry = hass.data[DOMAIN][DATA_DEVICES][webhook_id] diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index dc8094f2f79f86..0936f0d52d89fc 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -840,9 +840,7 @@ async def test_send_message_local_push_exception(hass: HomeAssistant) -> None: async def test_notify_live_activity_uses_stored_token( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver ) -> None: - """Test that live_update notifications route through a stored per-activity APNs token.""" - push_url = "https://mobile-push.home-assistant.dev/push" - + """Test that live_update notifications include live_activity_token in the relay payload.""" # Simulate the iOS app having registered a per-activity token via webhook. hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]["mock-webhook_id"] = { "washer_cycle": "LIVE_ACTIVITY_TOKEN_HEX" @@ -861,8 +859,9 @@ async def test_notify_live_activity_uses_stored_token( assert len(aioclient_mock.mock_calls) == 1 call_json = aioclient_mock.mock_calls[0][2] - # Should use the stored Live Activity token, not the FCM token. - assert call_json["push_token"] == "LIVE_ACTIVITY_TOKEN_HEX" + # FCM token stays as push_token; live activity APNs token is a separate field. + assert call_json["push_token"] == "PUSH_TOKEN" + assert call_json["live_activity_token"] == "LIVE_ACTIVITY_TOKEN_HEX" assert call_json["data"]["live_update"] is True assert call_json["data"]["tag"] == "washer_cycle" @@ -932,9 +931,9 @@ async def test_notify_live_activity_falls_back_to_push_to_start( assert len(aioclient_mock.mock_calls) == 1 call_json = aioclient_mock.mock_calls[0][2] - # Should use push-to-start token since no per-activity token is stored. - assert call_json["push_token"] == "PUSH_TO_START_HEX_TOKEN" - assert call_json["registration_info"]["apns_environment"] == "production" + # FCM token stays as push_token; push-to-start token is live_activity_token. + assert call_json["push_token"] == "FCM_TOKEN" + assert call_json["live_activity_token"] == "PUSH_TO_START_HEX_TOKEN" async def test_notify_live_activity_without_tag_uses_fcm( @@ -956,7 +955,7 @@ async def test_notify_live_activity_without_tag_uses_fcm( call_json = aioclient_mock.mock_calls[0][2] # Should use normal FCM token since there is no tag. assert call_json["push_token"] == "PUSH_TOKEN" - assert "apns_environment" not in call_json["registration_info"] + assert "live_activity_token" not in call_json async def test_notify_normal_notification_ignores_live_activity_tokens( @@ -983,3 +982,4 @@ async def test_notify_normal_notification_ignores_live_activity_tokens( call_json = aioclient_mock.mock_calls[0][2] # Should use normal FCM token — live_update flag not set. assert call_json["push_token"] == "PUSH_TOKEN" + assert "live_activity_token" not in call_json diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 8eee9815ef2417..33aba9a8bed1b1 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1332,8 +1332,6 @@ async def test_webhook_update_live_activity_token( "data": { "tag": "washer_cycle", "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", - "push_url": "http://localhost/mock-push/iOS/liveActivity/v1", - "apns_environment": "sandbox", }, }, ) @@ -1348,10 +1346,6 @@ async def test_webhook_update_live_activity_token( assert tokens[webhook_id]["washer_cycle"]["push_token"] == ( "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" ) - assert tokens[webhook_id]["washer_cycle"]["push_url"] == ( - "http://localhost/mock-push/iOS/liveActivity/v1" - ) - assert tokens[webhook_id]["washer_cycle"]["apns_environment"] == "sandbox" # Verify event was fired assert len(events) == 1 @@ -1360,12 +1354,12 @@ async def test_webhook_update_live_activity_token( assert events[0].data["webhook_id"] == webhook_id -async def test_webhook_update_live_activity_token_defaults_production( +async def test_webhook_update_live_activity_token_stores_only_push_token( hass: HomeAssistant, create_registrations: tuple[dict[str, Any], dict[str, Any]], webhook_client: TestClient, ) -> None: - """Test that apns_environment defaults to production.""" + """Test that stored token data contains only push_token (FCM handles routing).""" webhook_id = create_registrations[1]["webhook_id"] resp = await webhook_client.post( f"/api/webhook/{webhook_id}", @@ -1374,7 +1368,6 @@ async def test_webhook_update_live_activity_token_defaults_production( "data": { "tag": "ev_charge", "push_token": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - "push_url": "http://localhost/mock-push/iOS/liveActivity/v1", }, }, ) @@ -1382,7 +1375,10 @@ async def test_webhook_update_live_activity_token_defaults_production( assert resp.status == HTTPStatus.OK tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] - assert tokens[webhook_id]["ev_charge"]["apns_environment"] == "production" + stored = tokens[webhook_id]["ev_charge"] + assert stored == { + "push_token": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + } async def test_webhook_live_activity_dismissed( @@ -1405,7 +1401,6 @@ async def test_webhook_live_activity_dismissed( "data": { "tag": "washer_cycle", "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", - "push_url": "http://localhost/mock-push/iOS/liveActivity/v1", }, }, ) From d1163a56a50ff1b1043306028a5732b774b48530 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Mon, 23 Mar 2026 15:54:51 -0400 Subject: [PATCH 06/23] Address Copilot feedback: validate tag type and use constants in events - notify.py: guard against non-string tag values in notification payload to avoid runtime errors when used as dict key - webhook.py: use ATTR_DEVICE_ID and CONF_WEBHOOK_ID constants in event data instead of string literals for consistency Co-Authored-By: Claude Opus 4.6 (1M context) --- homeassistant/components/mobile_app/notify.py | 2 +- homeassistant/components/mobile_app/webhook.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 8dd7755a99352d..01e4612f698df5 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -237,7 +237,7 @@ def _get_live_activity_token( return None tag = notification_data.get(ATTR_LIVE_ACTIVITY_TAG) - if not tag: + if not tag or not isinstance(tag, str): return None # Per-activity token — the activity is already running on the device. diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 9d8e55c1dafc4c..3d35b084161018 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -836,8 +836,8 @@ async def webhook_update_live_activity_token( EVENT_LIVE_ACTIVITY_TOKEN_UPDATED, { ATTR_LIVE_ACTIVITY_TAG: activity_tag, - "device_id": device.id, - "webhook_id": webhook_id, + ATTR_DEVICE_ID: device.id, + CONF_WEBHOOK_ID: webhook_id, }, EventOrigin.remote, context=registration_context(config_entry.data), @@ -876,8 +876,8 @@ async def webhook_live_activity_dismissed( EVENT_LIVE_ACTIVITY_DISMISSED, { ATTR_LIVE_ACTIVITY_TAG: activity_tag, - "device_id": device.id, - "webhook_id": webhook_id, + ATTR_DEVICE_ID: device.id, + CONF_WEBHOOK_ID: webhook_id, }, EventOrigin.remote, context=registration_context(config_entry.data), From d29951924cb0e0462bdd7214c2982fd0943f851d Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Tue, 24 Mar 2026 10:06:34 -0400 Subject: [PATCH 07/23] Tighten input validation for Live Activity fields - webhook.py: reject empty push tokens with vol.Length(min=1) in update_live_activity_token schema - notify.py: use `is not True` for live_activity flag to prevent truthy non-bool values like string "false" from triggering Live Activity routing - const.py: reject empty push-to-start tokens with vol.Length(min=1) in SCHEMA_APP_DATA Co-Authored-By: Claude Opus 4.6 (1M context) --- homeassistant/components/mobile_app/const.py | 2 +- homeassistant/components/mobile_app/webhook.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index f7be419fb8752e..79539638d87c58 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -118,7 +118,7 @@ # without an environment is ambiguous (sandbox tokens fail on production). vol.Inclusive( ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, "live_activity_push_to_start" - ): cv.string, + ): vol.All(cv.string, vol.Length(min=1)), vol.Inclusive( ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT, "live_activity_push_to_start", diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 3d35b084161018..d4b654dfa5f1fd 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -809,7 +809,7 @@ async def webhook_scan_tag( @validate_schema( { vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, - vol.Required(ATTR_PUSH_TOKEN): cv.string, + vol.Required(ATTR_PUSH_TOKEN): vol.All(cv.string, vol.Length(min=1)), } ) async def webhook_update_live_activity_token( From b76e4056b1055a360f2283353e88e4c09a67fd11 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Wed, 25 Mar 2026 15:09:08 -0400 Subject: [PATCH 08/23] Address Copilot review: use ATTR_WEBHOOK_ID in events, validate dismissed tag, add unload test - Replace CONF_WEBHOOK_ID with ATTR_WEBHOOK_ID as the key in EVENT_LIVE_ACTIVITY_TOKEN_UPDATED and EVENT_LIVE_ACTIVITY_DISMISSED payloads to keep runtime event data semantically separate from config constants - Require non-empty ATTR_LIVE_ACTIVITY_TAG in the live_activity_dismissed webhook schema (vol.Length(min=1)) to match the update handler and prevent stale token store entries from empty tags - Add test_unload_removes_live_activity_tokens to verify live activity tokens are purged from hass.data when a config entry is unloaded Co-Authored-By: Claude Sonnet 4.6 --- .../components/mobile_app/webhook.py | 7 ++-- tests/components/mobile_app/test_init.py | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index d4b654dfa5f1fd..d80578b882cf39 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -93,6 +93,7 @@ ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, + ATTR_WEBHOOK_ID, ATTR_WEBHOOK_TYPE, CONF_CLOUDHOOK_URL, CONF_REMOTE_UI_URL, @@ -837,7 +838,7 @@ async def webhook_update_live_activity_token( { ATTR_LIVE_ACTIVITY_TAG: activity_tag, ATTR_DEVICE_ID: device.id, - CONF_WEBHOOK_ID: webhook_id, + ATTR_WEBHOOK_ID: webhook_id, }, EventOrigin.remote, context=registration_context(config_entry.data), @@ -849,7 +850,7 @@ async def webhook_update_live_activity_token( @WEBHOOK_COMMANDS.register("live_activity_dismissed") @validate_schema( { - vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, + vol.Required(ATTR_LIVE_ACTIVITY_TAG): vol.All(cv.string, vol.Length(min=1)), } ) async def webhook_live_activity_dismissed( @@ -877,7 +878,7 @@ async def webhook_live_activity_dismissed( { ATTR_LIVE_ACTIVITY_TAG: activity_tag, ATTR_DEVICE_ID: device.id, - CONF_WEBHOOK_ID: webhook_id, + ATTR_WEBHOOK_ID: webhook_id, }, EventOrigin.remote, context=registration_context(config_entry.data), diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index a67ed39b760339..86fef10c2b9201 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -1,9 +1,11 @@ """Tests for the mobile app integration.""" from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any from unittest.mock import Mock, patch +from aiohttp.test_utils import TestClient import pytest from homeassistant.components.cloud import CloudNotAvailable @@ -12,6 +14,7 @@ CONF_CLOUDHOOK_URL, CONF_USER_ID, DATA_DELETED_IDS, + DATA_LIVE_ACTIVITY_TOKENS, DOMAIN, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -615,3 +618,33 @@ def mock_listen_cloudhook_change(hass_instance, wh_id: str, callback): # URL should remain the same assert config_entry.data[CONF_CLOUDHOOK_URL] == new_url + + +@pytest.mark.usefixtures("create_registrations") +async def test_unload_removes_live_activity_tokens( + hass: HomeAssistant, webhook_client: TestClient +) -> None: + """Test that live activity tokens are removed from hass.data when entry is unloaded.""" + # Use the cleartext (non-encrypted) entry + config_entry = hass.config_entries.async_entries("mobile_app")[1] + webhook_id = config_entry.data["webhook_id"] + + # Store a live activity token via the webhook + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "update_live_activity_token", + "data": { + "tag": "washer_cycle", + "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + }, + }, + ) + assert resp.status == HTTPStatus.OK + assert webhook_id in hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] + + # Unload the config entry + await hass.config_entries.async_unload(config_entry.entry_id) + + # Verify the token is removed so stale tokens cannot be used after reloads/unloads + assert webhook_id not in hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] From d9df34fb5e4fe1b4cb22c4e81aa854209d73232a Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Thu, 26 Mar 2026 09:09:43 -0400 Subject: [PATCH 09/23] Require non-empty tag in update_live_activity_token webhook schema Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/webhook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index d80578b882cf39..216916a5771b80 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -809,7 +809,7 @@ async def webhook_scan_tag( @WEBHOOK_COMMANDS.register("update_live_activity_token") @validate_schema( { - vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, + vol.Required(ATTR_LIVE_ACTIVITY_TAG): vol.All(cv.string, vol.Length(min=1)), vol.Required(ATTR_PUSH_TOKEN): vol.All(cv.string, vol.Length(min=1)), } ) From ecbb296dd9e22193251ec3ae6976ee28d55a9c08 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Thu, 26 Mar 2026 10:27:01 -0400 Subject: [PATCH 10/23] Use live_update: true instead of live_activity: true for iOS Live Activities Unifies the iOS and Android notification data field: live_update: true now triggers Live Activity routing on iOS, matching the field Android already uses. Co-Authored-By: Claude Sonnet 4.6 --- tests/components/mobile_app/test_notify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 0936f0d52d89fc..12b945da5caaa3 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -840,7 +840,7 @@ async def test_send_message_local_push_exception(hass: HomeAssistant) -> None: async def test_notify_live_activity_uses_stored_token( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver ) -> None: - """Test that live_update notifications include live_activity_token in the relay payload.""" +"""Test that live_update notifications include live_activity_token in the relay payload.""" # Simulate the iOS app having registered a per-activity token via webhook. hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]["mock-webhook_id"] = { "washer_cycle": "LIVE_ACTIVITY_TOKEN_HEX" @@ -871,7 +871,7 @@ async def test_notify_live_activity_falls_back_to_push_to_start( aioclient_mock: AiohttpClientMocker, hass_admin_user: MockUser, ) -> None: - """Test that live_update without a stored token falls back to the push-to-start token.""" +"""Test that live_update without a stored token falls back to the push-to-start token.""" push_url = "https://mobile-push.home-assistant.dev/push" now = datetime.now() + timedelta(hours=24) iso_time = now.strftime("%Y-%m-%dT%H:%M:%SZ") From a16c8c9d302d1099f577e8e236fb3495ddf0e14c Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Wed, 1 Apr 2026 16:24:41 -0400 Subject: [PATCH 11/23] Remove unused bus events and supports_live_activities helper; simplify _get_live_activity_token signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop EVENT_LIVE_ACTIVITY_TOKEN_UPDATED and EVENT_LIVE_ACTIVITY_DISMISSED — nothing consumes these events in any of the three repos (no automation triggers, no iOS listener, no relay usage), so they add noise without value - Remove supports_live_activities() from util.py — defined but never called - Pass app_data directly into _get_live_activity_token instead of the full registration dict (edenhaus review feedback) - Group Live Activity push constants with other PUSH attrs in const.py - Update tests to remove event-capture assertions Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/const.py | 5 +--- homeassistant/components/mobile_app/util.py | 9 ------- .../components/mobile_app/webhook.py | 26 ------------------- tests/components/mobile_app/test_webhook.py | 25 ------------------ 4 files changed, 1 insertion(+), 64 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 79539638d87c58..48a2031515084f 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -41,17 +41,14 @@ ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT = ( "live_activity_push_to_start_apns_environment" ) - # Tag identifying a specific Live Activity instance — matches the `tag` field used by # the iOS companion app's ActivityKit integration. ATTR_LIVE_ACTIVITY_TAG = "tag" # In-memory store for per-device Live Activity push tokens, keyed by webhook_id → tag. -# Populated by update_live_activity_token and cleared by live_activity_dismissed webhooks. +# Populated by mobile_app_live_activity_token and cleared by mobile_app_live_activity_dismissed webhooks. DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens" -EVENT_LIVE_ACTIVITY_TOKEN_UPDATED = f"{DOMAIN}_live_activity_token_updated" -EVENT_LIVE_ACTIVITY_DISMISSED = f"{DOMAIN}_live_activity_dismissed" ATTR_PUSH_RATE_LIMITS = "rateLimits" ATTR_PUSH_RATE_LIMITS_ERRORS = "errors" ATTR_PUSH_RATE_LIMITS_MAXIMUM = "maximum" diff --git a/homeassistant/components/mobile_app/util.py b/homeassistant/components/mobile_app/util.py index 722f31911bf6ef..3c52e858a39692 100644 --- a/homeassistant/components/mobile_app/util.py +++ b/homeassistant/components/mobile_app/util.py @@ -15,7 +15,6 @@ ATTR_PUSH_TOKEN, ATTR_PUSH_URL, ATTR_PUSH_WEBSOCKET_CHANNEL, - ATTR_SUPPORTS_LIVE_ACTIVITIES, CONF_CLOUDHOOK_URL, DATA_CONFIG_ENTRIES, DATA_DEVICES, @@ -50,14 +49,6 @@ def supports_push(hass: HomeAssistant, webhook_id: str) -> bool: ) or ATTR_PUSH_WEBSOCKET_CHANNEL in app_data -@callback -def supports_live_activities(hass: HomeAssistant, webhook_id: str) -> bool: - """Return if the device supports iOS Live Activities.""" - config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] - app_data = config_entry.data.get(ATTR_APP_DATA, {}) - return bool(app_data.get(ATTR_SUPPORTS_LIVE_ACTIVITIES)) - - @callback def get_notify_service(hass: HomeAssistant, webhook_id: str) -> str | None: """Return the notify service for this webhook ID.""" diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 216916a5771b80..c659e7fe98e56e 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -109,8 +109,6 @@ ERR_ENCRYPTION_REQUIRED, ERR_INVALID_FORMAT, ERR_SENSOR_NOT_REGISTERED, - EVENT_LIVE_ACTIVITY_DISMISSED, - EVENT_LIVE_ACTIVITY_TOKEN_UPDATED, SCHEMA_APP_DATA, SENSOR_TYPES, SIGNAL_LOCATION_UPDATE, @@ -832,18 +830,6 @@ async def webhook_update_live_activity_token( ATTR_PUSH_TOKEN: data[ATTR_PUSH_TOKEN], } - device: dr.DeviceEntry = hass.data[DOMAIN][DATA_DEVICES][webhook_id] - hass.bus.async_fire( - EVENT_LIVE_ACTIVITY_TOKEN_UPDATED, - { - ATTR_LIVE_ACTIVITY_TAG: activity_tag, - ATTR_DEVICE_ID: device.id, - ATTR_WEBHOOK_ID: webhook_id, - }, - EventOrigin.remote, - context=registration_context(config_entry.data), - ) - return empty_okay_response() @@ -872,16 +858,4 @@ async def webhook_live_activity_dismissed( if not live_activity_tokens[webhook_id]: del live_activity_tokens[webhook_id] - device: dr.DeviceEntry = hass.data[DOMAIN][DATA_DEVICES][webhook_id] - hass.bus.async_fire( - EVENT_LIVE_ACTIVITY_DISMISSED, - { - ATTR_LIVE_ACTIVITY_TAG: activity_tag, - ATTR_DEVICE_ID: device.id, - ATTR_WEBHOOK_ID: webhook_id, - }, - EventOrigin.remote, - context=registration_context(config_entry.data), - ) - return empty_okay_response() diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 33aba9a8bed1b1..32aebb33663053 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -18,8 +18,6 @@ DATA_DEVICES, DATA_LIVE_ACTIVITY_TOKENS, DOMAIN, - EVENT_LIVE_ACTIVITY_DISMISSED, - EVENT_LIVE_ACTIVITY_TOKEN_UPDATED, ) from homeassistant.components.tag import EVENT_TAG_SCANNED from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN @@ -1314,16 +1312,10 @@ async def test_sending_sensor_state( async def test_webhook_update_live_activity_token( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, create_registrations: tuple[dict[str, Any], dict[str, Any]], webhook_client: TestClient, ) -> None: """Test that we can store a Live Activity push token.""" - device = device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")}) - assert device is not None - - events = async_capture_events(hass, EVENT_LIVE_ACTIVITY_TOKEN_UPDATED) - webhook_id = create_registrations[1]["webhook_id"] resp = await webhook_client.post( f"/api/webhook/{webhook_id}", @@ -1347,12 +1339,6 @@ async def test_webhook_update_live_activity_token( "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" ) - # Verify event was fired - assert len(events) == 1 - assert events[0].data["tag"] == "washer_cycle" - assert events[0].data["device_id"] == device.id - assert events[0].data["webhook_id"] == webhook_id - async def test_webhook_update_live_activity_token_stores_only_push_token( hass: HomeAssistant, @@ -1383,14 +1369,10 @@ async def test_webhook_update_live_activity_token_stores_only_push_token( async def test_webhook_live_activity_dismissed( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, create_registrations: tuple[dict[str, Any], dict[str, Any]], webhook_client: TestClient, ) -> None: """Test that we can dismiss a Live Activity and clean up its token.""" - device = device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")}) - assert device is not None - webhook_id = create_registrations[1]["webhook_id"] # First register a token @@ -1411,8 +1393,6 @@ async def test_webhook_live_activity_dismissed( assert "washer_cycle" in tokens[webhook_id] # Now dismiss it - events = async_capture_events(hass, EVENT_LIVE_ACTIVITY_DISMISSED) - resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ @@ -1430,11 +1410,6 @@ async def test_webhook_live_activity_dismissed( # Verify token was removed — webhook_id key also cleaned up since no activities remain assert webhook_id not in tokens - # Verify event was fired - assert len(events) == 1 - assert events[0].data["tag"] == "washer_cycle" - assert events[0].data["device_id"] == device.id - async def test_webhook_live_activity_dismissed_nonexistent_tag( hass: HomeAssistant, From 336c64bfe54d744ed6bd08f94685d71923b25353 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Wed, 1 Apr 2026 16:31:38 -0400 Subject: [PATCH 12/23] Rename live activity webhook tag field from 'tag' to 'live_activity_tag' The generic 'tag' field name collides with the notification tag used elsewhere in mobile_app. Using 'live_activity_tag' makes the webhook contract unambiguous. notify.py continues to read 'tag' from the notification payload (user YAML) unchanged. Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/const.py | 12 +++--------- homeassistant/components/mobile_app/notify.py | 3 +-- tests/components/mobile_app/test_webhook.py | 10 +++++----- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 48a2031515084f..0a278bc1d0a77b 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -41,9 +41,8 @@ ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT = ( "live_activity_push_to_start_apns_environment" ) -# Tag identifying a specific Live Activity instance — matches the `tag` field used by -# the iOS companion app's ActivityKit integration. -ATTR_LIVE_ACTIVITY_TAG = "tag" +# Tag identifying a specific Live Activity instance in the iOS companion app webhooks. +ATTR_LIVE_ACTIVITY_TAG = "live_activity_tag" # In-memory store for per-device Live Activity push tokens, keyed by webhook_id → tag. # Populated by mobile_app_live_activity_token and cleared by mobile_app_live_activity_dismissed webhooks. @@ -106,12 +105,7 @@ # Set to True to indicate that this registration will connect via websocket channel # to receive push notifications. vol.Optional(ATTR_PUSH_WEBSOCKET_CHANNEL): cv.boolean, - # iOS Live Activities capability flags and push-to-start token (iOS 17.2+). - # push-to-start allows HA to remotely start a new Live Activity on the device - # without requiring one to already be running. - vol.Optional(ATTR_SUPPORTS_LIVE_ACTIVITIES): cv.boolean, - vol.Optional(ATTR_SUPPORTS_LIVE_ACTIVITIES_FREQUENT_UPDATES): cv.boolean, - # push-to-start token and environment must be provided together — a token + # Push-to-start token and environment must be provided together — a token # without an environment is ambiguous (sandbox tokens fail on production). vol.Inclusive( ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, "live_activity_push_to_start" diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 01e4612f698df5..9ab563eed5eaec 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -37,7 +37,6 @@ ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, - ATTR_LIVE_ACTIVITY_TAG, ATTR_LIVE_UPDATE, ATTR_OS_VERSION, ATTR_PUSH_RATE_LIMITS, @@ -236,7 +235,7 @@ def _get_live_activity_token( if not notification_data.get(ATTR_LIVE_UPDATE): return None - tag = notification_data.get(ATTR_LIVE_ACTIVITY_TAG) + tag = notification_data.get("tag") if not tag or not isinstance(tag, str): return None diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 32aebb33663053..08bcbf29f37f1e 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1322,7 +1322,7 @@ async def test_webhook_update_live_activity_token( json={ "type": "update_live_activity_token", "data": { - "tag": "washer_cycle", + "live_activity_tag": "washer_cycle", "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", }, }, @@ -1352,7 +1352,7 @@ async def test_webhook_update_live_activity_token_stores_only_push_token( json={ "type": "update_live_activity_token", "data": { - "tag": "ev_charge", + "live_activity_tag": "ev_charge", "push_token": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", }, }, @@ -1381,7 +1381,7 @@ async def test_webhook_live_activity_dismissed( json={ "type": "update_live_activity_token", "data": { - "tag": "washer_cycle", + "live_activity_tag": "washer_cycle", "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", }, }, @@ -1398,7 +1398,7 @@ async def test_webhook_live_activity_dismissed( json={ "type": "live_activity_dismissed", "data": { - "tag": "washer_cycle", + "live_activity_tag": "washer_cycle", }, }, ) @@ -1424,7 +1424,7 @@ async def test_webhook_live_activity_dismissed_nonexistent_tag( json={ "type": "live_activity_dismissed", "data": { - "tag": "nonexistent_activity", + "live_activity_tag": "nonexistent_activity", }, }, ) From 023065f19f3d82f780c2188585dac63bcd6b1759 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Wed, 1 Apr 2026 16:33:50 -0400 Subject: [PATCH 13/23] Align webhook type names with iOS companion app Core registered 'update_live_activity_token' and 'live_activity_dismissed' but the iOS app sends 'mobile_app_live_activity_token' and 'mobile_app_live_activity_dismissed', matching the mobile_app_ prefix convention used elsewhere in the integration. Rename core handlers to match. Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/const.py | 2 +- homeassistant/components/mobile_app/webhook.py | 4 ++-- tests/components/mobile_app/test_webhook.py | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 0a278bc1d0a77b..0474d2ea9ff86a 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -44,7 +44,7 @@ # Tag identifying a specific Live Activity instance in the iOS companion app webhooks. ATTR_LIVE_ACTIVITY_TAG = "live_activity_tag" -# In-memory store for per-device Live Activity push tokens, keyed by webhook_id → tag. +# In-memory store for per-device Live Activity push tokens, keyed by webhook_id → live_activity_tag. # Populated by mobile_app_live_activity_token and cleared by mobile_app_live_activity_dismissed webhooks. DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens" diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index c659e7fe98e56e..b0a06a98c8318f 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -804,7 +804,7 @@ async def webhook_scan_tag( return empty_okay_response() -@WEBHOOK_COMMANDS.register("update_live_activity_token") +@WEBHOOK_COMMANDS.register("mobile_app_live_activity_token") @validate_schema( { vol.Required(ATTR_LIVE_ACTIVITY_TAG): vol.All(cv.string, vol.Length(min=1)), @@ -833,7 +833,7 @@ async def webhook_update_live_activity_token( return empty_okay_response() -@WEBHOOK_COMMANDS.register("live_activity_dismissed") +@WEBHOOK_COMMANDS.register("mobile_app_live_activity_dismissed") @validate_schema( { vol.Required(ATTR_LIVE_ACTIVITY_TAG): vol.All(cv.string, vol.Length(min=1)), diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 08bcbf29f37f1e..6ab78807dc2fd6 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1320,7 +1320,7 @@ async def test_webhook_update_live_activity_token( resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "update_live_activity_token", + "type": "mobile_app_live_activity_token", "data": { "live_activity_tag": "washer_cycle", "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", @@ -1350,7 +1350,7 @@ async def test_webhook_update_live_activity_token_stores_only_push_token( resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "update_live_activity_token", + "type": "mobile_app_live_activity_token", "data": { "live_activity_tag": "ev_charge", "push_token": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", @@ -1379,7 +1379,7 @@ async def test_webhook_live_activity_dismissed( await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "update_live_activity_token", + "type": "mobile_app_live_activity_token", "data": { "live_activity_tag": "washer_cycle", "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", @@ -1396,7 +1396,7 @@ async def test_webhook_live_activity_dismissed( resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "live_activity_dismissed", + "type": "mobile_app_live_activity_dismissed", "data": { "live_activity_tag": "washer_cycle", }, @@ -1422,7 +1422,7 @@ async def test_webhook_live_activity_dismissed_nonexistent_tag( resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "live_activity_dismissed", + "type": "mobile_app_live_activity_dismissed", "data": { "live_activity_tag": "nonexistent_activity", }, From 23ff06115ce5390024d64933d34758746aea9bed Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Thu, 2 Apr 2026 10:31:43 -0400 Subject: [PATCH 14/23] Remove unused ATTR_WEBHOOK_ID import from webhook.py Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/webhook.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index b0a06a98c8318f..7642b3dda5d32d 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -93,7 +93,6 @@ ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, - ATTR_WEBHOOK_ID, ATTR_WEBHOOK_TYPE, CONF_CLOUDHOOK_URL, CONF_REMOTE_UI_URL, From 61a609b46cf02d980dff1a87dcf76a3fa73f499e Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Thu, 2 Apr 2026 10:54:24 -0400 Subject: [PATCH 15/23] Fix test_init.py to use renamed webhook type and tag field Co-Authored-By: Claude Sonnet 4.6 --- tests/components/mobile_app/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index 86fef10c2b9201..e5983e2ba2cfe0 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -633,9 +633,9 @@ async def test_unload_removes_live_activity_tokens( resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "update_live_activity_token", + "type": "mobile_app_live_activity_token", "data": { - "tag": "washer_cycle", + "live_activity_tag": "washer_cycle", "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", }, }, From d5e847791c1fd26b98cf6fe8dbbaddacf69d656b Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Tue, 7 Apr 2026 10:20:52 -0400 Subject: [PATCH 16/23] Add comments clarifying live_update vs live_activity naming live_update is the cross-platform YAML key shared with Android; on iOS it maps to ActivityKit Live Activities, on Android to a different mechanism. The live_activity naming in webhook handlers and token storage is intentionally iOS-specific. Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/notify.py | 3 +++ homeassistant/components/mobile_app/webhook.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 9ab563eed5eaec..1a507975f7e531 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -232,6 +232,9 @@ def _get_live_activity_token( Returns None if this is a normal notification (not a Live Activity). """ notification_data = data.get(ATTR_DATA) or {} + # live_update is the cross-platform YAML key shared with Android. + # On iOS it maps to starting or updating an ActivityKit Live Activity; + # on Android it maps to a different mechanism (progress notifications). if not notification_data.get(ATTR_LIVE_UPDATE): return None diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 7642b3dda5d32d..d8cd87456b60a8 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -803,6 +803,10 @@ async def webhook_scan_tag( return empty_okay_response() +# The two webhooks below are iOS-specific. "live_activity" refers to +# ActivityKit Live Activities, an iOS-only feature. This is distinct from +# Android's live_update mechanism even though both are triggered by the +# cross-platform live_update: true notification key. @WEBHOOK_COMMANDS.register("mobile_app_live_activity_token") @validate_schema( { From 1337547177395bbba0233f89b17b3252bb66b301 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Tue, 28 Apr 2026 11:27:47 -0400 Subject: [PATCH 17/23] Address edenhaus review comments on Live Activity code - Remove unused ATTR_SUPPORTS_LIVE_ACTIVITIES and ATTR_SUPPORTS_LIVE_ACTIVITIES_FREQUENT_UPDATES constants and schema entries - Add ATTR_LIVE_UPDATE, ATTR_LIVE_ACTIVITY_TOKEN, and ATTR_PUSH_TAG constants; replace hardcoded strings - Simplify tag check: remove redundant isinstance(tag, str) guard - Use walrus operator for live activity token assignment in notify.py - Store live activity push token as a plain string instead of a dict - Remove comment before webhook registration already described in docstring - Simplify push-to-start token return to app_data.get(...) Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/const.py | 2 ++ homeassistant/components/mobile_app/notify.py | 8 +++++--- homeassistant/components/mobile_app/webhook.py | 8 +------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 0474d2ea9ff86a..19c304fd01e188 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -32,11 +32,13 @@ ATTR_NO_LEGACY_ENCRYPTION = "no_legacy_encryption" ATTR_OS_NAME = "os_name" ATTR_OS_VERSION = "os_version" +ATTR_PUSH_TAG = "tag" ATTR_PUSH_WEBSOCKET_CHANNEL = "push_websocket_channel" ATTR_PUSH_TOKEN = "push_token" ATTR_PUSH_URL = "push_url" ATTR_LIVE_UPDATE = "live_update" +ATTR_LIVE_ACTIVITY_TOKEN = "live_activity_token" ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN = "live_activity_push_to_start_token" ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT = ( "live_activity_push_to_start_apns_environment" diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 1a507975f7e531..4cbf879d83211e 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -37,7 +37,9 @@ ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, + ATTR_LIVE_ACTIVITY_TOKEN, ATTR_LIVE_UPDATE, + ATTR_PUSH_TAG, ATTR_OS_VERSION, ATTR_PUSH_RATE_LIMITS, ATTR_PUSH_RATE_LIMITS_ERRORS, @@ -238,8 +240,8 @@ def _get_live_activity_token( if not notification_data.get(ATTR_LIVE_UPDATE): return None - tag = notification_data.get("tag") - if not tag or not isinstance(tag, str): + tag = notification_data.get(ATTR_PUSH_TAG) + if not tag: return None # Per-activity token — the activity is already running on the device. @@ -299,7 +301,7 @@ async def _send_message( # server can set apns.liveActivityToken in the FCM payload. FCM then handles # apns-push-type: liveactivity and APNs routing automatically. if live_activity_token: - payload["live_activity_token"] = live_activity_token + payload[ATTR_LIVE_ACTIVITY_TOKEN] = live_activity_token try: async with asyncio.timeout(10): diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index d8cd87456b60a8..2ee33ffcc48869 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -803,10 +803,6 @@ async def webhook_scan_tag( return empty_okay_response() -# The two webhooks below are iOS-specific. "live_activity" refers to -# ActivityKit Live Activities, an iOS-only feature. This is distinct from -# Android's live_update mechanism even though both are triggered by the -# cross-platform live_update: true notification key. @WEBHOOK_COMMANDS.register("mobile_app_live_activity_token") @validate_schema( { @@ -829,9 +825,7 @@ async def webhook_update_live_activity_token( activity_tag = data[ATTR_LIVE_ACTIVITY_TAG] live_activity_tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] - live_activity_tokens.setdefault(webhook_id, {})[activity_tag] = { - ATTR_PUSH_TOKEN: data[ATTR_PUSH_TOKEN], - } + live_activity_tokens.setdefault(webhook_id, {})[activity_tag] = data[ATTR_PUSH_TOKEN] return empty_okay_response() From df217bd808ff2c02f6714ed92450e7a048e484ef Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Wed, 29 Apr 2026 11:02:33 -0400 Subject: [PATCH 18/23] Use cv.string for live activity webhook schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop vol.Length(min=1) from the tag and push token fields — consistent with webhook_scan_tag which uses plain cv.string. APNs tokens are explicitly variable length per Apple docs; hardcoding a minimum is both inconsistent and fragile. Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/webhook.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 2ee33ffcc48869..803fa34ae50950 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -806,8 +806,8 @@ async def webhook_scan_tag( @WEBHOOK_COMMANDS.register("mobile_app_live_activity_token") @validate_schema( { - vol.Required(ATTR_LIVE_ACTIVITY_TAG): vol.All(cv.string, vol.Length(min=1)), - vol.Required(ATTR_PUSH_TOKEN): vol.All(cv.string, vol.Length(min=1)), + vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, + vol.Required(ATTR_PUSH_TOKEN): cv.string, } ) async def webhook_update_live_activity_token( @@ -833,7 +833,7 @@ async def webhook_update_live_activity_token( @WEBHOOK_COMMANDS.register("mobile_app_live_activity_dismissed") @validate_schema( { - vol.Required(ATTR_LIVE_ACTIVITY_TAG): vol.All(cv.string, vol.Length(min=1)), + vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, } ) async def webhook_live_activity_dismissed( From 9d9ef5859665a67e3a7e10c220daa9a0f0025a00 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Wed, 29 Apr 2026 12:23:17 -0400 Subject: [PATCH 19/23] Fix docstring indentation in test_notify.py after merge conflict resolution Two Live Activity test docstrings lost their indentation when resolving rebase conflicts, causing a syntax error. Co-Authored-By: Claude Sonnet 4.6 --- tests/components/mobile_app/test_notify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 12b945da5caaa3..0936f0d52d89fc 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -840,7 +840,7 @@ async def test_send_message_local_push_exception(hass: HomeAssistant) -> None: async def test_notify_live_activity_uses_stored_token( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver ) -> None: -"""Test that live_update notifications include live_activity_token in the relay payload.""" + """Test that live_update notifications include live_activity_token in the relay payload.""" # Simulate the iOS app having registered a per-activity token via webhook. hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]["mock-webhook_id"] = { "washer_cycle": "LIVE_ACTIVITY_TOKEN_HEX" @@ -871,7 +871,7 @@ async def test_notify_live_activity_falls_back_to_push_to_start( aioclient_mock: AiohttpClientMocker, hass_admin_user: MockUser, ) -> None: -"""Test that live_update without a stored token falls back to the push-to-start token.""" + """Test that live_update without a stored token falls back to the push-to-start token.""" push_url = "https://mobile-push.home-assistant.dev/push" now = datetime.now() + timedelta(hours=24) iso_time = now.strftime("%Y-%m-%dT%H:%M:%SZ") From a1a6db3a663ee4b98c2feea644e132852f8478d3 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Wed, 29 Apr 2026 12:27:58 -0400 Subject: [PATCH 20/23] Rename live activity webhooks to drop mobile_app_ prefix Consistent with all other webhook types in this integration which use short names (scan_tag, update_location, etc.) without a mobile_app_ prefix. Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/webhook.py | 4 ++-- tests/components/mobile_app/test_webhook.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 803fa34ae50950..85bb0715a7ad9b 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -803,7 +803,7 @@ async def webhook_scan_tag( return empty_okay_response() -@WEBHOOK_COMMANDS.register("mobile_app_live_activity_token") +@WEBHOOK_COMMANDS.register("live_activity_token") @validate_schema( { vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, @@ -830,7 +830,7 @@ async def webhook_update_live_activity_token( return empty_okay_response() -@WEBHOOK_COMMANDS.register("mobile_app_live_activity_dismissed") +@WEBHOOK_COMMANDS.register("live_activity_dismissed") @validate_schema( { vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 6ab78807dc2fd6..85216b680f4916 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1320,7 +1320,7 @@ async def test_webhook_update_live_activity_token( resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "mobile_app_live_activity_token", + "type": "live_activity_token", "data": { "live_activity_tag": "washer_cycle", "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", @@ -1350,7 +1350,7 @@ async def test_webhook_update_live_activity_token_stores_only_push_token( resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "mobile_app_live_activity_token", + "type": "live_activity_token", "data": { "live_activity_tag": "ev_charge", "push_token": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", @@ -1379,7 +1379,7 @@ async def test_webhook_live_activity_dismissed( await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "mobile_app_live_activity_token", + "type": "live_activity_token", "data": { "live_activity_tag": "washer_cycle", "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", @@ -1396,7 +1396,7 @@ async def test_webhook_live_activity_dismissed( resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "mobile_app_live_activity_dismissed", + "type": "live_activity_dismissed", "data": { "live_activity_tag": "washer_cycle", }, @@ -1422,7 +1422,7 @@ async def test_webhook_live_activity_dismissed_nonexistent_tag( resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "mobile_app_live_activity_dismissed", + "type": "live_activity_dismissed", "data": { "live_activity_tag": "nonexistent_activity", }, From d44727e70be4214d7e8f13af5e40e07b55bb465f Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Wed, 29 Apr 2026 12:34:38 -0400 Subject: [PATCH 21/23] Fix prek formatting: remove unused import, sort imports, wrap long line - Remove unused AsyncGenerator import from notify.py - Sort ATTR_PUSH_TAG import alphabetically (after ATTR_PUSH_RATE_LIMITS_SUCCESSFUL) - Wrap long line in webhook_update_live_activity_token Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/notify.py | 3 +-- homeassistant/components/mobile_app/webhook.py | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 4cbf879d83211e..432d56df01476f 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator from functools import partial from http import HTTPStatus import logging @@ -39,13 +38,13 @@ ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, ATTR_LIVE_ACTIVITY_TOKEN, ATTR_LIVE_UPDATE, - ATTR_PUSH_TAG, ATTR_OS_VERSION, ATTR_PUSH_RATE_LIMITS, ATTR_PUSH_RATE_LIMITS_ERRORS, ATTR_PUSH_RATE_LIMITS_MAXIMUM, ATTR_PUSH_RATE_LIMITS_RESETS_AT, ATTR_PUSH_RATE_LIMITS_SUCCESSFUL, + ATTR_PUSH_TAG, ATTR_PUSH_TOKEN, ATTR_PUSH_URL, ATTR_WEBHOOK_ID, diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 85bb0715a7ad9b..ce96896346f16c 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -825,7 +825,9 @@ async def webhook_update_live_activity_token( activity_tag = data[ATTR_LIVE_ACTIVITY_TAG] live_activity_tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] - live_activity_tokens.setdefault(webhook_id, {})[activity_tag] = data[ATTR_PUSH_TOKEN] + live_activity_tokens.setdefault(webhook_id, {})[activity_tag] = data[ + ATTR_PUSH_TOKEN + ] return empty_okay_response() From eb478d4fd251fadff5b58c6364d7676defa44c10 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Wed, 29 Apr 2026 13:12:59 -0400 Subject: [PATCH 22/23] Fix live activity token storage format and stale webhook type in tests - Store per-activity tokens as {"push_token": } dict in DATA_LIVE_ACTIVITY_TOKENS so the structure matches test expectations and leaves room for additional fields without a schema change - Update _get_live_activity_token to read the push_token key from the dict - Update test_notify.py setups to use dict format - Fix stale "mobile_app_live_activity_token" type name in test_init.py (should be "live_activity_token" after the prefix-drop rename) Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/notify.py | 2 +- homeassistant/components/mobile_app/webhook.py | 6 +++--- tests/components/mobile_app/test_init.py | 2 +- tests/components/mobile_app/test_notify.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 432d56df01476f..c3d46a09699093 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -248,7 +248,7 @@ def _get_live_activity_token( live_activity_tokens = self.hass.data[DOMAIN].get(DATA_LIVE_ACTIVITY_TOKENS, {}) device_tokens = live_activity_tokens.get(webhook_id, {}) if tag in device_tokens: - return device_tokens[tag] + return device_tokens[tag][ATTR_PUSH_TOKEN] # Push-to-start token — start a new activity remotely (iOS 17.2+). app_data = entry.data[ATTR_APP_DATA] diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index ce96896346f16c..3eddb137f6ed61 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -825,9 +825,9 @@ async def webhook_update_live_activity_token( activity_tag = data[ATTR_LIVE_ACTIVITY_TAG] live_activity_tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] - live_activity_tokens.setdefault(webhook_id, {})[activity_tag] = data[ - ATTR_PUSH_TOKEN - ] + live_activity_tokens.setdefault(webhook_id, {})[activity_tag] = { + ATTR_PUSH_TOKEN: data[ATTR_PUSH_TOKEN] + } return empty_okay_response() diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index e5983e2ba2cfe0..0fb1cfddb7f1a4 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -633,7 +633,7 @@ async def test_unload_removes_live_activity_tokens( resp = await webhook_client.post( f"/api/webhook/{webhook_id}", json={ - "type": "mobile_app_live_activity_token", + "type": "live_activity_token", "data": { "live_activity_tag": "washer_cycle", "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 0936f0d52d89fc..b6454d055a3db2 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -843,7 +843,7 @@ async def test_notify_live_activity_uses_stored_token( """Test that live_update notifications include live_activity_token in the relay payload.""" # Simulate the iOS app having registered a per-activity token via webhook. hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]["mock-webhook_id"] = { - "washer_cycle": "LIVE_ACTIVITY_TOKEN_HEX" + "washer_cycle": {"push_token": "LIVE_ACTIVITY_TOKEN_HEX"} } await hass.services.async_call( @@ -964,7 +964,7 @@ async def test_notify_normal_notification_ignores_live_activity_tokens( """Test that normal notifications don't route through live activity tokens.""" # Store a live activity token — it should be ignored for non-live-activity pushes. hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]["mock-webhook_id"] = { - "some_tag": "SHOULD_NOT_USE_THIS" + "some_tag": {"push_token": "SHOULD_NOT_USE_THIS"} } await hass.services.async_call( From 978d802e15f916df735c155478aa07ac8bc1a3e9 Mon Sep 17 00:00:00 2001 From: Ryan Warner Date: Thu, 30 Apr 2026 11:15:14 -0400 Subject: [PATCH 23/23] mobile_app: simplify live activity comments and docstrings Co-Authored-By: Claude Sonnet 4.6 --- homeassistant/components/mobile_app/const.py | 32 ++++--------------- homeassistant/components/mobile_app/notify.py | 25 ++------------- .../components/mobile_app/webhook.py | 16 ++-------- 3 files changed, 11 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 19c304fd01e188..51fcc88d83a110 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -36,20 +36,6 @@ ATTR_PUSH_WEBSOCKET_CHANNEL = "push_websocket_channel" ATTR_PUSH_TOKEN = "push_token" ATTR_PUSH_URL = "push_url" - -ATTR_LIVE_UPDATE = "live_update" -ATTR_LIVE_ACTIVITY_TOKEN = "live_activity_token" -ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN = "live_activity_push_to_start_token" -ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT = ( - "live_activity_push_to_start_apns_environment" -) -# Tag identifying a specific Live Activity instance in the iOS companion app webhooks. -ATTR_LIVE_ACTIVITY_TAG = "live_activity_tag" - -# In-memory store for per-device Live Activity push tokens, keyed by webhook_id → live_activity_tag. -# Populated by mobile_app_live_activity_token and cleared by mobile_app_live_activity_dismissed webhooks. -DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens" - ATTR_PUSH_RATE_LIMITS = "rateLimits" ATTR_PUSH_RATE_LIMITS_ERRORS = "errors" ATTR_PUSH_RATE_LIMITS_MAXIMUM = "maximum" @@ -57,6 +43,12 @@ ATTR_PUSH_RATE_LIMITS_SUCCESSFUL = "successful" ATTR_SUPPORTS_ENCRYPTION = "supports_encryption" +ATTR_LIVE_UPDATE = "live_update" +ATTR_LIVE_ACTIVITY_TOKEN = "live_activity_token" +ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN = "live_activity_push_to_start_token" +ATTR_LIVE_ACTIVITY_TAG = "live_activity_tag" +DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens" + ATTR_EVENT_DATA = "event_data" ATTR_EVENT_TYPE = "event_type" @@ -104,18 +96,8 @@ { vol.Inclusive(ATTR_PUSH_TOKEN, "push_cloud"): cv.string, vol.Inclusive(ATTR_PUSH_URL, "push_cloud"): cv.url, - # Set to True to indicate that this registration will connect via websocket channel - # to receive push notifications. vol.Optional(ATTR_PUSH_WEBSOCKET_CHANNEL): cv.boolean, - # Push-to-start token and environment must be provided together — a token - # without an environment is ambiguous (sandbox tokens fail on production). - vol.Inclusive( - ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, "live_activity_push_to_start" - ): vol.All(cv.string, vol.Length(min=1)), - vol.Inclusive( - ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT, - "live_activity_push_to_start", - ): vol.In(["sandbox", "production"]), + vol.Optional(ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN): cv.string, }, extra=vol.ALLOW_EXTRA, ) diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index c3d46a09699093..05a3ca59938b39 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -219,23 +219,8 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: def _get_live_activity_token( self, entry: ConfigEntry, data: dict[str, Any] ) -> str | None: - """Return the Live Activity APNs token if this notification targets one. - - Checks whether the payload contains live_update: true and a tag. If a - per-activity APNs token is stored for that tag it is returned. Otherwise, - if the device has a push-to-start token, that is returned so the relay - server can start a new activity remotely. - - The token is sent alongside the regular FCM push_token as live_activity_token. - The relay places it in the FCM payload's apns.liveActivityToken field, and FCM - handles apns-push-type: liveactivity and APNs routing automatically. - - Returns None if this is a normal notification (not a Live Activity). - """ + """Return the Live Activity APNs token for this notification, or None.""" notification_data = data.get(ATTR_DATA) or {} - # live_update is the cross-platform YAML key shared with Android. - # On iOS it maps to starting or updating an ActivityKit Live Activity; - # on Android it maps to a different mechanism (progress notifications). if not notification_data.get(ATTR_LIVE_UPDATE): return None @@ -252,10 +237,7 @@ def _get_live_activity_token( # Push-to-start token — start a new activity remotely (iOS 17.2+). app_data = entry.data[ATTR_APP_DATA] - if token := app_data.get(ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN): - return token - - return None + return app_data.get(ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN) async def _async_send_remote_message_target( self, entry: ConfigEntry, data: dict[str, Any] @@ -296,9 +278,6 @@ async def _send_message( ATTR_PUSH_TOKEN: entry.data[ATTR_APP_DATA][ATTR_PUSH_TOKEN], "registration_info": reg_info, } - # If this is a Live Activity notification, include the APNs token so the relay - # server can set apns.liveActivityToken in the FCM payload. FCM then handles - # apns-push-type: liveactivity and APNs routing automatically. if live_activity_token: payload[ATTR_LIVE_ACTIVITY_TOKEN] = live_activity_token diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 3eddb137f6ed61..468e8a6cd291b6 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -813,14 +813,7 @@ async def webhook_scan_tag( async def webhook_update_live_activity_token( hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, Any] ) -> Response: - """Handle a Live Activity token update from the iOS companion app. - - When the iOS app creates a Live Activity locally, ActivityKit provides - a per-activity APNs push token. The app sends this token so HA can - later include it as live_activity_token in the push relay request. - The relay server places it in the FCM message's apns.liveActivityToken - field, and FCM handles APNs delivery automatically. - """ + """Store a Live Activity APNs token sent by the iOS app.""" webhook_id = config_entry.data[CONF_WEBHOOK_ID] activity_tag = data[ATTR_LIVE_ACTIVITY_TAG] @@ -841,12 +834,7 @@ async def webhook_update_live_activity_token( async def webhook_live_activity_dismissed( hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, str] ) -> Response: - """Handle a Live Activity dismissal from the iOS companion app. - - When a Live Activity ends on the device (user dismissal, expiration, - or an explicit end event), the app notifies HA so the stored push - token for that activity can be cleaned up. - """ + """Remove a stored Live Activity token when the activity ends on device.""" webhook_id = config_entry.data[CONF_WEBHOOK_ID] activity_tag = data[ATTR_LIVE_ACTIVITY_TAG]