Compare commits

..

5 Commits

15 changed files with 214 additions and 27 deletions
+82 -17
View File
@@ -1,33 +1,98 @@
## 1. Installation (HACS) # Shopping List Manager Integration for Home Assistant
### Recommended The backend integration that powers the Shopping List Manager. Provides persistent multi-list storage, a 500+ product catalog, real-time WebSocket events, and a full API for the Lovelace card — all running natively inside Home Assistant.
> **Pair with the [Shopping List Manager Card](https://github.com/thekiwismarthome/shopping-list-manager-card)** for the full UI experience.
[![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager&category=integration) [![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager&category=integration)
1. Click the button above. ---
2. Confirm adding the repository to HACS.
3. Install **Shopping List Manager** from **HACS → Integrations**. ## Features
4. Restart Home Assistant.
### 🛒 Multi-List Management
- Create and manage multiple shopping lists
- Private or shared lists with per-member access control
- Active list state shared across all connected devices and users
- List total price calculation
### 📦 Items
- Add, update, check, and delete items with quantity and unit
- Atomic quantity increment / decrement
- Bulk check and clear checked items
- Per-item pricing, notes, and category assignment
### 🔍 Product Catalog
- **500+ products** (NZ-focused, extensible to AU, US, GB, CA)
- Fuzzy search with alias matching
- Recently-used product suggestions
- Custom product creation
- Allergen filtering and product substitute groups
- Product images (WebP, 200×200px, optimised)
### 🗂️ Categories
- 13 default categories — Produce, Dairy, Meat, Bakery, Pantry, Frozen, Beverages, Snacks, Household, Health, Pet, Baby, Other
- Category colour coding and emoji icons
- Per-list category ordering
### 💳 Loyalty Cards
- Store loyalty and rewards card data
- Private or shared card access per user
### 🔄 Real-Time Events
- All changes fire events on the Home Assistant bus
- Custom WebSocket subscription proxy so **non-admin users** receive live updates without requiring HA admin privileges
--- ---
### Manual Repository URL ## Requirements
https://github.com/thekiwismarthome/shopping-list-manager | Component | Minimum Version |
|---|---|
Repository type: **Integration** | Home Assistant | 2024.1 |
| HACS | 2.x |
--- ---
## 2. Manual Installation (Optional) ## Installation
1. Copy the folder: ### Via HACS (Recommended)
custom_components/shopping_list_manager
2. Paste it into: [![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager&category=integration)
/config/custom_components/
3. Restart Home Assistant. 1. Click the button above
2. Confirm adding the repository to HACS
3. Install **Shopping List Manager** from **HACS → Integrations**
4. Restart Home Assistant
5. Go to **Settings → Devices & Services → Add Integration** and search for **Shopping List Manager**
### Manual Installation
1. Copy the `custom_components/shopping_list_manager/` folder into your HA `/config/custom_components/` directory
2. Restart Home Assistant
3. Go to **Settings → Devices & Services → Add Integration** and search for **Shopping List Manager**
---
## Lovelace Card
Install the companion card to get the full shopping UI:
## 3. Shopping List Card to go with this Integration
[![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager-card&category=plugin) [![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager-card&category=plugin)
---
## Documentation
Full documentation is available in the [Wiki](https://github.com/thekiwismarthome/shopping-list-manager/wiki).
## Support & Feedback
- [Open an Issue](https://github.com/thekiwismarthome/shopping-list-manager/issues)
- [Home Assistant Community Forum](https://community.home-assistant.io)
---
## License
MIT — see [LICENSE](LICENSE) for details.
@@ -171,6 +171,10 @@ async def _async_register_websocket_handlers(
) )
# Products handlers # Products handlers
websocket_api.async_register_command(
hass,
handlers.websocket_search_by_barcode,
)
websocket_api.async_register_command( websocket_api.async_register_command(
hass, hass,
handlers.websocket_search_products, handlers.websocket_search_products,
@@ -48,6 +48,62 @@ from .. import get_storage
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# =============================================================================
# ACCESS-CHECK HELPERS
# =============================================================================
def _user_can_access_list(lst, user) -> bool:
"""Return True if the user may read or write to this list.
Global lists (owner_id=None) are accessible to everyone.
Private lists are accessible to their owner, anyone in allowed_users, and admins.
"""
if lst.owner_id is None:
return True
if user is None:
return False
if user.is_admin or user.id == lst.owner_id:
return True
return user.id in (lst.allowed_users or [])
def _check_list_access(storage, connection, msg, list_id, require_owner=False):
"""Verify the connected user may access list_id.
Sends the appropriate WebSocket error if access is denied.
Returns the ShoppingList object on success, or None if an error was sent.
Args:
require_owner: When True, only the list owner (or an admin) is allowed.
Use for destructive/administrative operations.
"""
lst = storage.get_list(list_id)
if lst is None:
connection.send_error(msg["id"], "not_found", "List not found")
return None
user = connection.user
if require_owner:
if lst.owner_id is not None and not (user and (user.is_admin or user.id == lst.owner_id)):
connection.send_error(msg["id"], "forbidden", "Only the list owner can perform this action")
return None
else:
if not _user_can_access_list(lst, user):
connection.send_error(msg["id"], "forbidden", "You do not have access to this list")
return None
return lst
def _find_item_list_id(storage, item_id):
"""Return the list_id that contains item_id, or None if not found."""
for list_id, items in storage._items.items():
for item in items:
if item.id == item_id:
return list_id
return None
# ============================================================================= # =============================================================================
# LIST HANDLERS # LIST HANDLERS
# ============================================================================= # =============================================================================
@@ -62,16 +118,28 @@ async def websocket_subscribe(
msg: dict, msg: dict,
) -> None: ) -> None:
"""Subscribe to shopping list manager events via WebSocket.""" """Subscribe to shopping list manager events via WebSocket."""
storage = get_storage(hass)
@callback @callback
def forward_event(event): def forward_event(event):
"""Forward HA bus event to WebSocket connection.""" """Forward HA bus event to WebSocket connection.
Events that reference a list_id are only forwarded if the connected
user has access to that list, preventing cross-user data leakage.
"""
data = event.data
list_id = data.get("list_id")
if list_id:
lst = storage.get_list(list_id)
if lst and not _user_can_access_list(lst, connection.user):
return # skip — user cannot see this list
connection.send_message( connection.send_message(
websocket_api.event_message( websocket_api.event_message(
msg["id"], msg["id"],
{ {
"event_type": event.event_type, "event_type": event.event_type,
"data": event.data, "data": data,
} }
) )
) )
@@ -248,7 +316,10 @@ async def websocket_update_list(
"""Handle update list command.""" """Handle update list command."""
storage = get_storage(hass) storage = get_storage(hass)
list_id = msg["list_id"] list_id = msg["list_id"]
if _check_list_access(storage, connection, msg, list_id, require_owner=True) is None:
return
# Build update kwargs # Build update kwargs
update_data = {} update_data = {}
if "name" in msg: if "name" in msg:
@@ -334,7 +405,10 @@ async def websocket_set_active_list(
"""Handle set active list command.""" """Handle set active list command."""
storage = get_storage(hass) storage = get_storage(hass)
list_id = msg["list_id"] list_id = msg["list_id"]
if _check_list_access(storage, connection, msg, list_id) is None:
return
success = await storage.set_active_list(list_id) success = await storage.set_active_list(list_id)
if not success: if not success:
@@ -369,7 +443,10 @@ def websocket_get_items(
"""Handle get items command.""" """Handle get items command."""
storage = get_storage(hass) storage = get_storage(hass)
list_id = msg["list_id"] list_id = msg["list_id"]
if _check_list_access(storage, connection, msg, list_id) is None:
return
items = storage.get_items(list_id) items = storage.get_items(list_id)
connection.send_result( connection.send_result(
@@ -404,7 +481,10 @@ async def websocket_add_item(
"""Handle add item command.""" """Handle add item command."""
storage = get_storage(hass) storage = get_storage(hass)
list_id = msg["list_id"] list_id = msg["list_id"]
if _check_list_access(storage, connection, msg, list_id) is None:
return
# Build item data # Build item data
item_data = { item_data = {
"name": msg["name"], "name": msg["name"],
@@ -463,7 +543,14 @@ async def websocket_update_item(
"""Handle update item command.""" """Handle update item command."""
storage = get_storage(hass) storage = get_storage(hass)
item_id = msg["item_id"] item_id = msg["item_id"]
list_id = _find_item_list_id(storage, item_id)
if list_id is None:
connection.send_error(msg["id"], "not_found", "Item not found")
return
if _check_list_access(storage, connection, msg, list_id) is None:
return
# Build update data # Build update data
update_data = {} update_data = {}
update_fields = ["name", "quantity", "unit", "note", "price", "category_id", "image_url"] update_fields = ["name", "quantity", "unit", "note", "price", "category_id", "image_url"]
@@ -548,7 +635,14 @@ async def websocket_delete_item(
"""Handle delete item command.""" """Handle delete item command."""
storage = get_storage(hass) storage = get_storage(hass)
item_id = msg["item_id"] item_id = msg["item_id"]
list_id = _find_item_list_id(storage, item_id)
if list_id is None:
connection.send_error(msg["id"], "not_found", "Item not found")
return
if _check_list_access(storage, connection, msg, list_id) is None:
return
success = await storage.delete_item(item_id) success = await storage.delete_item(item_id)
if not success: if not success:
@@ -688,6 +782,28 @@ def websocket_get_list_total(
# PRODUCT HANDLERS # PRODUCT HANDLERS
# ============================================================================= # =============================================================================
@websocket_api.websocket_command(
{
vol.Required("type"): "shopping_list_manager/products/search_by_barcode",
vol.Required("barcode"): str,
}
)
@callback
def websocket_search_by_barcode(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: Dict[str, Any],
) -> None:
"""Find a single product by exact barcode match."""
storage = get_storage(hass)
barcode = msg["barcode"].strip()
match = next(
(p for p in storage._products.values() if p.barcode and p.barcode == barcode),
None,
)
connection.send_result(msg["id"], {"product": match.to_dict() if match else None})
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "shopping_list_manager/products/substitutes", vol.Required("type"): "shopping_list_manager/products/substitutes",
@@ -931,10 +1047,12 @@ def websocket_get_integration_settings(
) )
_VALID_COUNTRIES = ["NZ", "AU", "US", "GB", "CA"]
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "shopping_list_manager/set_country", vol.Required("type"): "shopping_list_manager/set_country",
vol.Required("country"): str, vol.Required("country"): vol.In(_VALID_COUNTRIES),
} }
) )
@websocket_api.async_response @websocket_api.async_response