-
-
Notifications
You must be signed in to change notification settings - Fork 37.5k
Add iOS Live Activity webhook handlers to mobile_app #166072
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 29 commits
7d4713a
e377da7
14a6987
3d7ea81
3f6346d
d1163a5
d299519
b76e405
d9df34f
ecbb296
a16c8c9
336c64b
023065f
23ff061
61a609b
d5e8477
1337547
df217bd
9d9ef58
a1a6db3
d44727e
eb478d4
978d802
1437794
25340ac
883f1f8
b0cb713
16fde1c
83ee21a
e5ae0fa
983ed24
0ec8f4e
0f092eb
917a4fc
8c85dbf
fccb524
bbf2bfe
8b41416
0eea88e
c4f85ec
0d00b7f
4926cec
78b5f66
1c98aeb
3660474
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ | |
| ) | ||
| from homeassistant.helpers.storage import Store | ||
| from homeassistant.helpers.typing import ConfigType | ||
| from homeassistant.util import dt as dt_util | ||
|
|
||
| # Pre-import the platforms so they get loaded when the integration | ||
| # is imported as they are almost always going to be loaded and its | ||
|
|
@@ -42,10 +43,12 @@ | |
| DATA_CONFIG_ENTRIES, | ||
| DATA_DELETED_IDS, | ||
| DATA_DEVICES, | ||
| DATA_LIVE_ACTIVITY_TOKENS, | ||
| DATA_PENDING_UPDATES, | ||
| DATA_PUSH_CHANNEL, | ||
| DATA_STORE, | ||
| DOMAIN, | ||
| LIVE_ACTIVITY_TOKEN_TTL_SECONDS, | ||
| SENSOR_TYPES, | ||
| STORAGE_KEY, | ||
| STORAGE_VERSION, | ||
|
|
@@ -73,14 +76,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: | |
| app_config, dict | ||
| ): | ||
| app_config = { | ||
| DATA_CONFIG_ENTRIES: {}, | ||
| DATA_DELETED_IDS: [], | ||
| DATA_LIVE_ACTIVITY_TOKENS: {}, | ||
| } | ||
| elif DATA_LIVE_ACTIVITY_TOKENS not in app_config: | ||
| app_config[DATA_LIVE_ACTIVITY_TOKENS] = {} | ||
|
|
||
| cutoff = dt_util.utcnow().timestamp() - LIVE_ACTIVITY_TOKEN_TTL_SECONDS | ||
| live_activity_tokens: dict[str, Any] = { | ||
| wh_id: { | ||
| tag: entry | ||
| for tag, entry in tags.items() | ||
| if entry.get("stored_at", 0) > cutoff | ||
| } | ||
| for wh_id, tags in app_config[DATA_LIVE_ACTIVITY_TOKENS].items() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if statement is duplicated for example. Can we make the code nicer?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replaced the nest with a loop and re ran the tests once more for this. Let me know if I can make further improvements |
||
| if any(entry.get("stored_at", 0) > cutoff for entry in tags.values()) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we found invalid entries during setup we should update the store.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good. Now going to track the earliest expiration during the startup loop. If expired tokens are found we save immediately and if there are valid ones, we schedule a single cleanup task that
Also added tests for both behaviors. |
||
| } | ||
|
|
||
| hass.data[DOMAIN] = { | ||
| DATA_CONFIG_ENTRIES: {}, | ||
| DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []), | ||
| DATA_DEVICES: {}, | ||
| DATA_LIVE_ACTIVITY_TOKENS: live_activity_tokens, | ||
| DATA_PUSH_CHANNEL: {}, | ||
| DATA_STORE: store, | ||
| DATA_PENDING_UPDATES: {sensor_type: {} for sensor_type in SENSOR_TYPES}, | ||
|
|
@@ -231,6 +248,8 @@ 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] | ||
| if hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS].pop(webhook_id, None) is not None: | ||
| await hass.data[DOMAIN][DATA_STORE].async_save(savable_state(hass)) | ||
|
edenhaus marked this conversation as resolved.
Outdated
|
||
| await hass_notify.async_reload(hass, DOMAIN) | ||
|
|
||
| return True | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -37,22 +37,29 @@ | |||||
| 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, | ||||||
| DATA_STORE, | ||||||
| DOMAIN, | ||||||
| LIVE_ACTIVITY_TOKEN_TTL_SECONDS, | ||||||
| SIGNAL_RECORD_NOTIFICATION, | ||||||
| ) | ||||||
| from .helpers import device_info | ||||||
| from .helpers import device_info, savable_state | ||||||
| from .push_notification import PushChannel | ||||||
| from .util import supports_push | ||||||
|
|
||||||
|
|
@@ -231,12 +238,51 @@ 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" | ||||||
| ) | ||||||
|
|
||||||
| 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_PUSH_TAG) | ||||||
| if not tag: | ||||||
| return None | ||||||
|
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, {}) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
use
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok thanks sounds good. Fixed in both places as well as |
||||||
| device_tokens = live_activity_tokens.get(webhook_id, {}) | ||||||
| if stored := device_tokens.get(tag): | ||||||
| if ( | ||||||
| dt_util.utcnow().timestamp() - stored.get("stored_at", 0) | ||||||
| < LIVE_ACTIVITY_TOKEN_TTL_SECONDS | ||||||
| ): | ||||||
| return stored["token"] | ||||||
| # Token expired — remove it lazily. | ||||||
| device_tokens.pop(tag, None) | ||||||
| if not device_tokens: | ||||||
| live_activity_tokens.pop(webhook_id, None) | ||||||
| await self.hass.data[DOMAIN][DATA_STORE].async_save( | ||||||
| savable_state(self.hass) | ||||||
| ) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment is not matching the code as you remove it immediately.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for catching that. I removed the eager cleanup and now the scheduled task handles it. Notification path just skips the expired token and falls through to push-to-start |
||||||
|
|
||||||
| # 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=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)) | ||||||
|
|
@@ -245,7 +291,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 = { | ||||||
|
|
@@ -256,15 +306,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() | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,7 @@ | |
| template, | ||
| ) | ||
| from homeassistant.helpers.dispatcher import async_dispatcher_send | ||
| from homeassistant.util import dt as dt_util | ||
| from homeassistant.util.decorator import Registry | ||
|
|
||
| from .const import ( | ||
|
|
@@ -57,10 +58,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, | ||
|
|
@@ -87,7 +90,9 @@ | |
| DATA_CONFIG_ENTRIES, | ||
| DATA_DELETED_IDS, | ||
| DATA_DEVICES, | ||
| DATA_LIVE_ACTIVITY_TOKENS, | ||
| DATA_PENDING_UPDATES, | ||
| DATA_STORE, | ||
| DOMAIN, | ||
| ERR_ENCRYPTION_ALREADY_ENABLED, | ||
| ERR_ENCRYPTION_REQUIRED, | ||
|
|
@@ -107,6 +112,7 @@ | |
| error_response, | ||
| registration_context, | ||
| safe_registration, | ||
| savable_state, | ||
| webhook_response, | ||
| ) | ||
|
|
||
|
|
@@ -771,3 +777,51 @@ 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, | ||
|
rwarner marked this conversation as resolved.
|
||
| vol.Required(ATTR_PUSH_TOKEN): cv.string, | ||
|
rwarner marked this conversation as resolved.
|
||
| } | ||
| ) | ||
| 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] = { | ||
| "token": data[ATTR_PUSH_TOKEN], | ||
| "stored_at": dt_util.utcnow().timestamp(), | ||
| } | ||
|
rwarner marked this conversation as resolved.
rwarner marked this conversation as resolved.
|
||
| await hass.data[DOMAIN][DATA_STORE].async_save(savable_state(hass)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use async_delay_save with a sane delay as we can get a lot of webhook updates
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea, switched both with a 10 second delay |
||
|
|
||
| return empty_okay_response() | ||
|
|
||
|
|
||
| @WEBHOOK_COMMANDS.register("live_activity_dismissed") | ||
| @validate_schema( | ||
| { | ||
| vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, | ||
|
rwarner marked this conversation as resolved.
|
||
| } | ||
| ) | ||
| 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
+834
to
+837
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can the dismissed be called multiple time for a activitiy tag?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dismissed is only called once per tag in normal flow, but since
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So just double checking... And if it is correct, is there a technical limitation, why we don't allow live activity to be survive a restart?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Sorry, yes that was used in that instance because I thought it would do a better job explaining the overall scope. My apologies.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should store the token so there feature survives a restart. A dict is fine, the token as key and the time as value maybe. Okay we leave the bad token for a follow up PR. |
||
| await hass.data[DOMAIN][DATA_STORE].async_save(savable_state(hass)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here |
||
|
|
||
| return empty_okay_response() | ||
Uh oh!
There was an error while loading. Please reload this page.