Skip to content
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

add event for new list items #12

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
152 changes: 148 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,150 @@ use the built-in services to add, remove and update items from your synchronized
- `todo.remove_item`
- `todo.update_item`

## Events

Google recently removed third party integrations from Google Assistant. Now you can re-create these integrations.

When a new item is added to a synced list via Google Assistant or from Google Keep, an `add_item` service call event will be triggered. This allows Home Assistant to pick up new items from Google Keep and sync them with third party systems such as Trello, Bring, Anylist etc.

This plugin extends Home Assistant's events so that the `add_item` service call is fired regardless of where the new item was added. The `origin` field in the event will be `REMOTE` if the item was added remotely to Google Keep, or `LOCAL` if it was added within Home Assistant.

Note: Only new items that are not completed at the time of syncing will trigger the event.

Below are some examples of how to do this, click to expand.

<details>
<summary>Sync Google Todo List with Trello via email</summary>

1. Install and configure this plugin

1. Install and configure the Anylist plugin

1. Create an email notify service in Home assistant

1. Create a new [app password](https://myaccount.google.com/apppasswords>) in your Gmail.

1. Setup [creating cards by email]([https://support.atlassian.com/trello/docs/creating-cards-by-email/) in Trello

1. Add the following to your config.yaml file.

```yaml
notify:
- name: "Email to Trello Todo"
platform: smtp
server: "smtp.gmail.com"
port: 587
timeout: 15
encryption: starttls
sender: "[email protected]"
username: "[email protected]"
password: "app password"
sender_name: "your name"
recipient: "[email protected]"
```

1. Create an Automation in Home Assistant:

```yaml
alias: Google Todo List
description: Send new items added to Google's Todo List to Trello
trigger:
- platform: event
event_type: call_service
event_data:
domain: todo
service: add_item
variables:
item_name: "{{ trigger.event.data.service_data.item }}"
list_name: "{{state_attr((trigger.event.data.service_data.entity_id)[0],'friendly_name')}}"
list_entity_id: "{{ (trigger.event.data.service_data.entity_id)[0] }}"
origin: "{{ trigger.event.origin }}"
condition:
# Update this to the name of your To-do list in Home Assistant.
- condition: template
value_template: "{{ list_entity_id == 'todo.google_keep_to_do' }}"
action:
# Optional: Send a notification of new item in HA.
- service: notify.persistent_notification
data:
message: "'{{item_name}}' was just added to the '{{list_name}}' list."
# Call Home Assistant Notify service to send item to Trello Board.
- service: notify.email_to_trello_todo
data:
title: "{{item_name}}"
message:
# Complete item from google shopping list. Can also call todo.remove_item to delete it from the list.
# Update entity_id to the id of your google list in Home Assistant.
- service: todo.update_item
target:
entity_id: todo.google_keep_to_do
data:
status: completed
item: "{{item_name}}"
# The maximum number of updates you want to process each update. If you make frequent changes, increase this number.
mode: parallel
max: 50
```

</details>

<details>
<summary>Sync Google Shopping List with Anylist</summary>

The same process works for Bring Shopping list or any other integrated list to Home Assistant.

1. Install and configure this plugin

1. Install and configure the Anylist plugin

1. Create an Automation in Home Assistant:

```yaml
alias: Google Shopping List
description: Sync Google Shopping List with Anylist
trigger:
- platform: event
event_type: call_service
event_data:
domain: todo
service: add_item
variables:
item_name: "{{ trigger.event.data.service_data.item }}"
list_name: "{{state_attr((trigger.event.data.service_data.entity_id)[0],'friendly_name')}}"
list_entity_id: "{{ (trigger.event.data.service_data.entity_id)[0] }}"
origin: "{{ trigger.event.origin }}"
condition:
# Update this to the name of your To-do list in Home Assistant.
- condition: template
value_template: "{{ list_entity_id == 'todo.google_keep_to_do' }}"
action:
# Optional: Send a notification of new item in Home Assistant.
- service: notify.persistent_notification
data:
message: "'{{item_name}}' was just added to the '{{list_name}}' list."
# Add new item to your Anylist list
# Update the entity_id list name to your list in Home Assistant.
- service: todo.add_item
data:
item: "{{item_name}}"
target:
entity_id: todo.anylist_alexa_shopping_list
enabled: true
# Complete item from google shopping list. Can also call todo.remove_item to delete it from the list
# Update entity_id to the id of your google list in Home Assistant.
- service: todo.update_item
target:
entity_id: todo.google_keep_shopping_list
data:
status: completed
item: "{{item_name}}"
# The maximum number of updates you want to process each update. If you make frequent changes, increase this number.
mode: parallel
max: 50
```

</details>

## Limitations

- **Polling Interval**: While changes made in Home Assistant are instantly reflected in Google Keep, changes made in Google Keep are not instantly reflected in Home Assistant. The integration polls Google Keep for updates every 15 minutes. Therefore, any changes made directly in Google Keep will be visible in Home Assistant after the next polling interval.
Expand Down Expand Up @@ -116,10 +260,10 @@ To generate a token:

1. In a environment with Docker installed, enter the following commands.

```bash
docker pull breph/ha-google-home_get-token:latest
docker run -it -d breph/ha-google-home_get-token
```
```bash
docker pull breph/ha-google-home_get-token:latest
docker run -it -d breph/ha-google-home_get-token
```

2. Copy the returned container ID to use in the following command.

Expand Down
39 changes: 7 additions & 32 deletions custom_components/google_keep_sync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
from __future__ import annotations

import logging
from datetime import timedelta

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .api import GoogleKeepAPI
from .const import DOMAIN
from .coordinator import GoogleKeepSyncCoordinator

PLATFORMS: list[Platform] = [Platform.TODO]

Expand All @@ -28,42 +27,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.error("Failed to authenticate Google Keep API")
return False # Exit early if authentication fails

# Define the update method for the coordinator
async def async_update_data():
"""Fetch data from API."""
try:
# Directly call the async_sync_data method
lists_to_sync = entry.data.get("lists_to_sync", [])

# Check if we need to sort the lists
auto_sort = entry.data.get("list_auto_sort", False)

return await api.async_sync_data(lists_to_sync, auto_sort)
except Exception as error:
raise UpdateFailed(f"Error communicating with API: {error}") from error

# Create the coordinator
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="Google Keep",
update_method=async_update_data,
update_interval=timedelta(minutes=15),
)
coordinator = GoogleKeepSyncCoordinator(hass, api, entry)

# Start the data update coordinator
await coordinator.async_refresh()
await coordinator.async_config_entry_first_refresh()

# Store the API and coordinator objects in hass.data
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"api": api,
"coordinator": coordinator,
}
# Store the coordinator object in hass.data
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator

# Forward the setup to the todo platform
hass.async_create_task(
hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True

Expand Down
3 changes: 3 additions & 0 deletions custom_components/google_keep_sync/const.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
"""Constants for the Google Keep Sync integration."""

from datetime import timedelta

DOMAIN = "google_keep_sync"
SCAN_INTERVAL = timedelta(minutes=15)
144 changes: 144 additions & 0 deletions custom_components/google_keep_sync/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""DataUpdateCoordinator for the Google Keep Sync component."""

import logging
from collections import namedtuple

from gkeepapi.node import List as GKeepList
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_CALL_SERVICE, Platform
from homeassistant.core import EventOrigin, HomeAssistant
from homeassistant.helpers import entity_registry
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .api import GoogleKeepAPI
from .const import DOMAIN, SCAN_INTERVAL

_LOGGER = logging.getLogger(__name__)
TodoItem = namedtuple("TodoItem", ["summary", "checked"])
TodoList = namedtuple("TodoList", ["name", "items"])
TodoItemData = namedtuple("TodoItemData", ["item", "entity_id"])


class GoogleKeepSyncCoordinator(DataUpdateCoordinator[list[GKeepList]]):
"""Coordinator for updating task data from Google Keep."""

def __init__(
self,
hass: HomeAssistant,
api: GoogleKeepAPI,
entry: ConfigEntry,
) -> None:
"""Initialize the Google Keep Todo coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self.api = api
self.config_entry = entry

# Define the update method for the coordinator
async def _async_update_data(self) -> list[GKeepList]:
"""Fetch data from API."""
try:
# save lists prior to syncing
original_lists = await self._parse_gkeep_data_dict()

# Sync data with Google Keep
lists_to_sync = self.config_entry.data.get("lists_to_sync", [])
auto_sort = self.config_entry.data.get("list_auto_sort", False)
result = await self.api.async_sync_data(lists_to_sync, auto_sort)

# save lists after syncing
updated_lists = await self._parse_gkeep_data_dict()
# compare both list for changes, and fire event for changes
new_items = await self._get_new_items_added(
original_lists,
updated_lists,
)
await self._notify_new_items(new_items)

return result
except Exception as error:
raise UpdateFailed(f"Error communicating with API: {error}") from error

async def _parse_gkeep_data_dict(self) -> dict[str, TodoList]:
"""Parse unchecked gkeep data to a dictionary, with the list id as the key."""
all_keep_lists = {}

# for each list
for keep_list in self.data or []:
# get all the unchecked items only
items = {
item.id: TodoItem(summary=item.text, checked=item.checked)
for item in keep_list.items
if not item.checked
}
all_keep_lists[keep_list.id] = TodoList(name=keep_list.title, items=items)
return all_keep_lists

async def _get_new_items_added(
self,
original_lists: dict[str, TodoList],
updated_lists: dict[str, TodoList],
) -> list[TodoItemData]:
"""Compare original and updated lists to find new TodoItems.

For each new TodoItem found, call the provided on_new_item callback.

:param original_lists: The original todo list data.
:param updated_lists: The updated todo list data.
# :param on_new_item: Callback function to execute for each new item found.
"""
new_items = []
# for each list
for updated_list_id, updated_list in updated_lists.items():
if updated_list_id not in original_lists:
_LOGGER.debug("Found new list not in original: %s", updated_list.name)
continue

# for each todo item in the list
updated_list_item: TodoItem
for (
updated_list_item_id,
updated_list_item,
) in updated_list.items.items():
# get original list with updated_list_id
original_list = original_lists[updated_list_id]

# if todo is not in original list, then it is new
if updated_list_item_id not in original_list.items:
# Get HA List entity_id for _gkeep_list_id
entity_reg = entity_registry.async_get(self.hass)
uuid = f"{DOMAIN}.list.{updated_list_id}"
list_entity_id = entity_reg.async_get_entity_id(
Platform.TODO, DOMAIN, uuid
)
new_items.append(
TodoItemData(
item=updated_list_item.summary, entity_id=list_entity_id
)
)

_LOGGER.debug(
"Found new TodoItem: '%s' in List entity_id: '%s'",
updated_list_item.summary,
list_entity_id,
)
return new_items

async def _notify_new_items(self, new_items: list[TodoItemData]) -> None:
"""Emit add_item service call event for new remote Todo items."""
for new_item in new_items:
event_data = {
"domain": "todo",
"service": "add_item",
"service_data": {
"item": new_item.item,
"entity_id": [new_item.entity_id],
},
}
self.hass.bus.async_fire(
EVENT_CALL_SERVICE, event_data, origin=EventOrigin.remote
)
Loading