Skip to content
Closed
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
53 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
1437794
mobile_app: restore websocket channel comment in SCHEMA_APP_DATA
rwarner May 7, 2026
25340ac
mobile_app: persist live activity tokens across restarts with TTL cle…
rwarner May 7, 2026
883f1f8
Merge upstream/dev into feat/ios-live-activity
rwarner May 7, 2026
b0cb713
mobile_app: fold live activity tokens into existing store
rwarner May 7, 2026
16fde1c
mobile_app: fix pylint hass-use-runtime-data in savable_state
rwarner May 7, 2026
83ee21a
mobile_app: fix import order in test_notify
rwarner May 7, 2026
e5ae0fa
mobile_app: clean up live activity tokens on remove, not unload
rwarner May 19, 2026
983ed24
mobile_app: add store migration for live_activity_tokens (v1 → v2)
rwarner May 19, 2026
0ec8f4e
mobile_app: remove duplicate condition in live activity token filter
rwarner May 19, 2026
0f092eb
mobile_app: save store and schedule cleanup on startup for live activ…
rwarner May 19, 2026
917a4fc
mobile_app: use direct indexing where keys are guaranteed to exist
rwarner May 19, 2026
8c85dbf
mobile_app: remove eager token cleanup from notify, rely on cleanup task
rwarner May 19, 2026
fccb524
Merge upstream/dev into branch
rwarner May 19, 2026
bbf2bfe
mobile_app: fix ruff formatting — line length and import order
rwarner May 19, 2026
8b41416
mobile_app: drop unnecessary delay floor in token cleanup scheduling
rwarner May 20, 2026
0eea88e
mobile_app: reuse cleanup function at startup instead of duplicating it
rwarner May 20, 2026
c4f85ec
mobile_app: use async_call_later to schedule live activity token cleanup
rwarner May 20, 2026
0d00b7f
mobile_app: restart live activity token cleanup when a token is added
rwarner May 20, 2026
4926cec
mobile_app: index live activity token stored_at directly
rwarner May 20, 2026
78b5f66
mobile_app: debounce live activity token store writes
rwarner May 20, 2026
1c98aeb
mobile_app: bump store minor version instead of major for the new field
rwarner May 20, 2026
3660474
Merge remote-tracking branch 'upstream/dev' into feat/ios-live-activity
rwarner May 20, 2026
3afe179
Merge branch 'dev' into feat/ios-live-activity
edenhaus May 26, 2026
4227077
Restructure live activty
edenhaus May 26, 2026
94b4a8d
Refactor tests
edenhaus May 26, 2026
334f24b
Apply suggestions from code review
edenhaus May 27, 2026
3097854
Rename variable
edenhaus May 27, 2026
135cd18
mobile_app: translate flat Live Activity payload and end via clear_no…
rwarner May 27, 2026
a7f2052
mobile_app: address review feedback for live activities
rwarner May 28, 2026
b7ffeac
mobile_app: alphabetize ATTR_TOKEN import in webhook.py
rwarner May 28, 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
34 changes: 27 additions & 7 deletions homeassistant/components/mobile_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,19 @@
DATA_CONFIG_ENTRIES,
DATA_DELETED_IDS,
DATA_DEVICES,
DATA_LIVE_ACTIVITY_TOKENS,
DATA_PENDING_UPDATES,
DATA_PUSH_CHANNEL,
DATA_STORE,
DOMAIN,
SENSOR_TYPES,
STORAGE_KEY,
STORAGE_VERSION,
STORAGE_VERSION_MINOR,
)
from .helpers import async_is_local_only_user, savable_state
from .http_api import RegistrationsView
from .live_activity import async_cleanup_expired_tokens
from .timers import async_handle_timer_event
from .util import async_create_cloud_hook, supports_push
from .webhook import handle_webhook
Expand All @@ -72,24 +75,26 @@

async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the mobile app component."""
store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
store = _MobileAppStore(
hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
)
if (app_config := await store.async_load()) is None or not isinstance(
app_config, dict
):
app_config = {
DATA_CONFIG_ENTRIES: {},
DATA_DELETED_IDS: [],
}
app_config = {DATA_DELETED_IDS: [], DATA_LIVE_ACTIVITY_TOKENS: {}}

hass.data[DOMAIN] = {
DATA_CONFIG_ENTRIES: {},
DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []),
DATA_DELETED_IDS: app_config[DATA_DELETED_IDS],
DATA_DEVICES: {},
DATA_LIVE_ACTIVITY_TOKENS: app_config[DATA_LIVE_ACTIVITY_TOKENS],
DATA_PUSH_CHANNEL: {},
DATA_STORE: store,
DATA_PENDING_UPDATES: {sensor_type: {} for sensor_type in SENSOR_TYPES},
}

hass.async_create_task(async_cleanup_expired_tokens(hass))

hass.http.register_view(RegistrationsView())

for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]:
Expand Down Expand Up @@ -243,10 +248,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Cleanup when entry is removed."""
hass.data[DOMAIN][DATA_DELETED_IDS].append(entry.data[CONF_WEBHOOK_ID])
webhook_id = entry.data[CONF_WEBHOOK_ID]
hass.data[DOMAIN][DATA_DELETED_IDS].append(webhook_id)
hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS].pop(webhook_id, None)
store = hass.data[DOMAIN][DATA_STORE]
await store.async_save(savable_state(hass))

if CONF_CLOUDHOOK_URL in entry.data:
with suppress(cloud.CloudNotAvailable, ValueError):
await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])


class _MobileAppStore(Store[dict[str, Any]]):
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.

This could grow further we add migrations, I would advise to move this into it's own file? Not sure if it's the usual way to do in Python.

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.

True, will defer to @edenhaus

async def _async_migrate_func(
self,
old_major_version: int,
old_minor_version: int,
old_data: dict[str, Any],
) -> dict[str, Any]:
"""Migrate mobile_app storage to the current version."""
if old_major_version == 1 and old_minor_version < 2:
old_data.setdefault(DATA_LIVE_ACTIVITY_TOKENS, {})
return old_data
11 changes: 11 additions & 0 deletions homeassistant/components/mobile_app/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 2
STORAGE_SAVE_DELAY = 10
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.

Suggested change
STORAGE_SAVE_DELAY = 10
STORAGE_SAVE_DELAY_SECONDS = 10

I suppose?

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.

Good call updated and fixed references


# A Live Activity can be active for up to eight hours unless its app or a person ends it before this limit. https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities#Understand-constraints
LIVE_ACTIVITY_TOKEN_TTL_SECONDS = 8 * 3600
Comment thread
edenhaus marked this conversation as resolved.

CONF_CLOUDHOOK_URL = "cloudhook_url"
CONF_REMOTE_UI_URL = "remote_ui_url"
Expand All @@ -17,6 +22,7 @@
DATA_CONFIG_ENTRIES = "config_entries"
DATA_DELETED_IDS = "deleted_ids"
DATA_DEVICES = "devices"
DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens"
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 we make this specific to iOS in the name to avoid any confusion? Android don't uses this, same for the other new ATTRs.

Copy link
Copy Markdown
Author

@rwarner rwarner May 28, 2026

Choose a reason for hiding this comment

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

Yes good idea, I will hold off on this until there's an architectural decision with the whole platform specific code going into this. If this is going into a mobile_app/ios/ or something then the prefix might be redundant? But I can keep this open for now and can update them if that still sounds like a good direction

DATA_STORE = "store"
DATA_NOTIFY = "notify"
DATA_PUSH_CHANNEL = "push_channel"
Expand All @@ -40,6 +46,10 @@
ATTR_PUSH_RATE_LIMITS_SUCCESSFUL = "successful"
ATTR_SUPPORTS_ENCRYPTION = "supports_encryption"

ATTR_LIVE_UPDATE = "live_update"
ATTR_LIVE_ACTIVITY_TOKEN = "live_activity_token"
ATTR_TAG = "tag"

ATTR_EVENT_DATA = "event_data"
ATTR_EVENT_TYPE = "event_type"

Expand Down Expand Up @@ -92,6 +102,7 @@
# will connect via websocket channel to receive
# push notifications.
vol.Optional(ATTR_PUSH_WEBSOCKET_CHANNEL): cv.boolean,
vol.Optional(ATTR_LIVE_ACTIVITY_TOKEN): cv.string,
},
extra=vol.ALLOW_EXTRA,
)
Expand Down
8 changes: 5 additions & 3 deletions homeassistant/components/mobile_app/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
CONF_SECRET,
CONF_USER_ID,
DATA_DELETED_IDS,
DATA_LIVE_ACTIVITY_TOKENS,
DOMAIN,
)

Expand Down Expand Up @@ -170,10 +171,11 @@ def safe_registration(registration: dict) -> dict:

def savable_state(hass: HomeAssistant) -> dict:
"""Return a clean object containing things that should be saved."""
# pylint: disable-next=home-assistant-use-runtime-data
domain_data = hass.data[DOMAIN]
return {
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=home-assistant-use-runtime-data
DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS],
DATA_DELETED_IDS: domain_data[DATA_DELETED_IDS],
DATA_LIVE_ACTIVITY_TOKENS: domain_data[DATA_LIVE_ACTIVITY_TOKENS],
}


Expand Down
64 changes: 64 additions & 0 deletions homeassistant/components/mobile_app/live_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Live Activity push token lifecycle: expiry-driven cleanup loop."""
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.

Add a mention that it is only for iOS.

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.

@edenhaus Is it a "valid" pattern to actually move specific code for iOS into a dedicated python module like mobile_app/ios we don't know yet how we want the integration to evolve but things that are specific to a platform could be isolated a bit to help with whatever change we might do in the future.

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.

Will hold on this until we make that architectural decision

# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern

from datetime import datetime

from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util

from .const import DATA_LIVE_ACTIVITY_TOKENS, DATA_STORE, DOMAIN
from .helpers import savable_state


@callback
def async_schedule_next_cleanup(hass: HomeAssistant) -> None:
"""Schedule a sweep for the earliest token expiry.

Only call when no sweep is already in flight. The invariant is: tokens
non-empty ⟹ sweep scheduled. So the two safe call sites are (1) the
webhook that added the first token (tokens was empty beforehand) and
(2) the tail of a sweep that found surviving tokens (its own timer
just fired).
"""
tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]
earliest_expires_at = min(
(
token["expires_at"]
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.

Wouldit make sense to extract "expires_at" into a const? It is used in multiple places.

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.

Good idea, introduced ATTR_EXPIRES_AT and implemented

for device_tokens in tokens.values()
for token in device_tokens.values()
),
default=None,
)
if earliest_expires_at is None:
return

delay = earliest_expires_at - dt_util.utcnow().timestamp()

async def run_cleanup(_now: datetime) -> None:
await async_cleanup_expired_tokens(hass)

async_call_later(hass, delay, run_cleanup)


async def async_cleanup_expired_tokens(hass: HomeAssistant) -> None:
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.

Shouldn't this be

Suggested change
async def async_cleanup_expired_tokens(hass: HomeAssistant) -> None:
async def _async_cleanup_expired_tokens(hass: HomeAssistant) -> None:

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.

I'm not a big python guy so I'm not sure what the proper format should be. It looks like because both functions are called from __init__.py and webhook.py the module is public by design which constitutes not having the _

I would defer to @edenhaus I might have my understanding mixed up

"""Sweep expired tokens, keep the loop alive if any remain, save changes."""
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.

I'm not sure to understand what is the loop in this context. Maybe because of my lack of context, but maybe it means that we are lacking some documentation.

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.

You need a token to update a live activity, these tokens may expire, so async_cleanup_expired_tokens cleans expired tokens. Straightforward in my opinion

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 to @bgoncal's explanation. I expanded the function's comment with the specific "what's the loop" detail.

now = dt_util.utcnow().timestamp()
tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]
changed = False

for webhook_id in list(tokens):
device_tokens = tokens[webhook_id]
for tag, data in list(device_tokens.items()):
if data["expires_at"] <= now:
del device_tokens[tag]
changed = True
if not device_tokens:
del tokens[webhook_id]
changed = True

if tokens:
async_schedule_next_cleanup(hass)

if changed:
await hass.data[DOMAIN][DATA_STORE].async_save(savable_state(hass))
58 changes: 50 additions & 8 deletions homeassistant/components/mobile_app/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
ATTR_APP_ID,
ATTR_APP_VERSION,
ATTR_DEVICE_NAME,
ATTR_LIVE_ACTIVITY_TOKEN,
ATTR_LIVE_UPDATE,
ATTR_OS_VERSION,
ATTR_PUSH_RATE_LIMITS,
ATTR_PUSH_RATE_LIMITS_ERRORS,
Expand All @@ -45,8 +47,10 @@
ATTR_PUSH_RATE_LIMITS_SUCCESSFUL,
ATTR_PUSH_TOKEN,
ATTR_PUSH_URL,
ATTR_TAG,
ATTR_WEBHOOK_ID,
DATA_CONFIG_ENTRIES,
DATA_LIVE_ACTIVITY_TOKENS,
DATA_NOTIFY,
DATA_PUSH_CHANNEL,
DOMAIN,
Expand Down Expand Up @@ -234,12 +238,42 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
" not connected to local push notifications"
)

async 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_TAG)
if not tag:
return None
Comment thread
rwarner marked this conversation as resolved.

webhook_id = entry.data[ATTR_WEBHOOK_ID]
live_activity_tokens = self.hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]
device_tokens = live_activity_tokens.get(webhook_id, {})
if (stored := device_tokens.get(tag)) and stored[
"expires_at"
] > dt_util.utcnow().timestamp():
# The activity is already running on the device and the token is valid
return stored["token"]
Comment thread
edenhaus marked this conversation as resolved.
Outdated

# Start a new activity remotely
app_data = entry.data[ATTR_APP_DATA]
return app_data.get(ATTR_LIVE_ACTIVITY_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=await 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 @@ -248,7 +282,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 @@ -259,15 +297,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
Loading
Loading