Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7d4713a
Add iOS Live Activity webhook handlers to mobile_app integration
rwarner Mar 20, 2026
e377da7
Use constants for event names and add EventOrigin.remote
rwarner Mar 20, 2026
14a6987
Address Copilot review: Inclusive validation and token cleanup
rwarner Mar 20, 2026
3d7ea81
Wire up notify.py to route Live Activity pushes through APNs relay
rwarner Mar 20, 2026
3f6346d
Simplify Live Activity routing — use FCM native liveActivityToken
rwarner Mar 21, 2026
d1163a5
Address Copilot feedback: validate tag type and use constants in events
rwarner Mar 23, 2026
d299519
Tighten input validation for Live Activity fields
rwarner Mar 24, 2026
b76e405
Address Copilot review: use ATTR_WEBHOOK_ID in events, validate dismi…
rwarner Mar 25, 2026
d9df34f
Require non-empty tag in update_live_activity_token webhook schema
rwarner Mar 26, 2026
ecbb296
Use live_update: true instead of live_activity: true for iOS Live Act…
rwarner Mar 26, 2026
a16c8c9
Remove unused bus events and supports_live_activities helper; simplif…
rwarner Apr 1, 2026
336c64b
Rename live activity webhook tag field from 'tag' to 'live_activity_tag'
rwarner Apr 1, 2026
023065f
Align webhook type names with iOS companion app
rwarner Apr 1, 2026
23ff061
Remove unused ATTR_WEBHOOK_ID import from webhook.py
rwarner Apr 2, 2026
61a609b
Fix test_init.py to use renamed webhook type and tag field
rwarner Apr 2, 2026
d5e8477
Add comments clarifying live_update vs live_activity naming
rwarner Apr 7, 2026
1337547
Address edenhaus review comments on Live Activity code
rwarner Apr 28, 2026
df217bd
Use cv.string for live activity webhook schemas
rwarner Apr 29, 2026
9d9ef58
Fix docstring indentation in test_notify.py after merge conflict reso…
rwarner Apr 29, 2026
a1a6db3
Rename live activity webhooks to drop mobile_app_ prefix
rwarner Apr 29, 2026
d44727e
Fix prek formatting: remove unused import, sort imports, wrap long line
rwarner Apr 29, 2026
eb478d4
Fix live activity token storage format and stale webhook type in tests
rwarner Apr 29, 2026
978d802
mobile_app: simplify live activity comments and docstrings
rwarner Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions homeassistant/components/mobile_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
DATA_CONFIG_ENTRIES,
DATA_DELETED_IDS,
DATA_DEVICES,
DATA_LIVE_ACTIVITY_TOKENS,
DATA_PENDING_UPDATES,
DATA_PUSH_CHANNEL,
DATA_STORE,
Expand Down Expand Up @@ -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: {},
Comment thread
rwarner marked this conversation as resolved.
DATA_STORE: store,
DATA_PENDING_UPDATES: {sensor_type: {} for sensor_type in SENSOR_TYPES},
Expand Down Expand Up @@ -231,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)
Comment thread
rwarner marked this conversation as resolved.
await hass_notify.async_reload(hass, DOMAIN)

return True
Expand Down
10 changes: 8 additions & 2 deletions homeassistant/components/mobile_app/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
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"
Expand All @@ -42,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"

Expand Down Expand Up @@ -89,9 +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,
vol.Optional(ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN): cv.string,
},
extra=vol.ALLOW_EXTRA,
)
Expand Down
57 changes: 49 additions & 8 deletions homeassistant/components/mobile_app/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,21 @@
ATTR_APP_ID,
ATTR_APP_VERSION,
ATTR_DEVICE_NAME,
ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN,
ATTR_LIVE_ACTIVITY_TOKEN,
ATTR_LIVE_UPDATE,
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,
DATA_CONFIG_ENTRIES,
DATA_LIVE_ACTIVITY_TOKENS,
DATA_NOTIFY,
DATA_PUSH_CHANNEL,
DOMAIN,
Expand Down Expand Up @@ -211,12 +216,40 @@ 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]
) -> str | None:
"""Return the Live Activity APNs token for this notification, or None."""
notification_data = data.get(ATTR_DATA) or {}
if not notification_data.get(ATTR_LIVE_UPDATE):
return None

tag = notification_data.get(ATTR_PUSH_TAG)
if not tag:
return None
Comment thread
rwarner marked this conversation as resolved.

# 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 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]
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]
):
) -> None:
"""Send a message to a target."""
try:
await _send_message(async_get_clientsession(self.hass), entry, data)
await _send_message(
async_get_clientsession(self.hass),
entry,
data,
live_activity_token=self._get_live_activity_token(entry, data),
)
except HomeAssistantError as e:
if e.translation_key == "rate_limit_exceeded_sending_notification":
_LOGGER.warning(str(e))
Expand All @@ -225,7 +258,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_token: str | None = None,
) -> None:
"""Shared internal helper to send messages via cloud push notification services."""
reg_info = {
Expand All @@ -236,15 +273,19 @@ async def _send_message(
if ATTR_OS_VERSION in entry.data:
reg_info[ATTR_OS_VERSION] = entry.data[ATTR_OS_VERSION]

payload: dict[str, Any] = {
**data,
ATTR_PUSH_TOKEN: entry.data[ATTR_APP_DATA][ATTR_PUSH_TOKEN],
"registration_info": reg_info,
}
if live_activity_token:
payload[ATTR_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: entry.data[ATTR_APP_DATA][ATTR_PUSH_TOKEN],
"registration_info": reg_info,
},
json=payload,
)
result: dict[str, Any] = await response.json()

Expand Down
48 changes: 48 additions & 0 deletions homeassistant/components/mobile_app/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@
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_SENSOR_ATTRIBUTES,
ATTR_SENSOR_DEVICE_CLASS,
ATTR_SENSOR_DISABLED,
Expand Down Expand Up @@ -99,6 +101,7 @@
DATA_CONFIG_ENTRIES,
DATA_DELETED_IDS,
DATA_DEVICES,
DATA_LIVE_ACTIVITY_TOKENS,
DATA_PENDING_UPDATES,
DOMAIN,
ERR_ENCRYPTION_ALREADY_ENABLED,
Expand Down Expand Up @@ -798,3 +801,48 @@ async def webhook_scan_tag(
registration_context(config_entry.data),
)
return empty_okay_response()


@WEBHOOK_COMMANDS.register("live_activity_token")
@validate_schema(
{
vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string,
vol.Required(ATTR_PUSH_TOKEN): cv.string,
}
)
async def webhook_update_live_activity_token(
hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, Any]
) -> Response:
"""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]

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]
}

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:
"""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]

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]
Comment on lines +843 to +846
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the dismissed be called multiple time for a activitiy tag?
If not we should remove the code of handling gracefully the removal

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dismissed is only called once per tag in normal flow, but since DATA_LIVE_ACTIVITY_TOKENS is in-memory, HA restart clears all stored tokens. The iOS app can still fire a dismissed webhook for an activity started before the restart, so the graceful check prevents a KeyError in that case rather than guarding against duplicate calls. Happy to add a comment clarifying that if it helps.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So just double checking...
If a user starts a a live activity for the washing machine (one use case I thought about using it) and during the washing machine is running, he will install a HA update and so HA will restart, he can't anymore update the live activity. Is that correct?

And if it is correct, is there a technical limitation, why we don't allow live activity to be survive a restart?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's correct. HA restart loses all in-memory Live Activity tokens. The existing activity continues showing the last known state on the device (iOS keeps it alive for up to 8 hours), but HA can no longer send updates to it.

The iOS app does handle recovery automatically in one case: if the iOS companion app is also restarted (or even just brought to foreground after reconnecting), it calls reattach() at launch, re-observes Activity.pushTokenUpdates, and immediately re-sends the current token to HA. So a full device/app restart recovers cleanly.

The specific gap is HA restarting while the iOS app stays running. The app has no trigger to proactively re-push existing tokens on HA reconnect. We could persist tokens to the config entry, but a stored token from before the restart may have rotated in the interim, and pushing to a stale token returns BadDeviceToken from APN, which we don't currently handle. For now this is a known limitation. A future improvement could be a "request token re-registration" webhook that HA calls on startup to prompt the iOS app to re-send all active tokens.

Open to suggestions or concerns, or marking down the code with a comment about this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BadDeviceToken from APN

Why aren't we handling it as it could always happen that a token is invalid?

We could just simply use a store to store, together with the actual time, so we know after 8h, maybe for safety use a longer timeframe, we can clean them up

The comments sound like written directly by AI, which is not allowed. Please write them in your own words. Using AI for help is fine but the human must stay in the loop

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Storing a timestamp alongside the token would require a dict rather than a bare string. Is that the right direction, or is there a preferred method you'd want me to do?

  2. If we do a cleanup should we use a periodic async_track_time_interval job, or something else?

  3. Regarding BadDeviceToken: that error comes back from APNs via the relay server, which currently doesn't report it back to HA. Is timestamp-based expiry sufficient as a standalone fix, or would you also want relay changes to actively report invalid tokens?

The comments sound like written directly by AI, which is not allowed. Please write them in your own words. Using AI for help is fine but the human must stay in the loop

Sorry, yes that was used in that instance because I thought it would do a better job explaining the overall scope. My apologies.


return empty_okay_response()
33 changes: 33 additions & 0 deletions tests/components/mobile_app/test_init.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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": "live_activity_token",
"data": {
"live_activity_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]
Loading
Loading