mirror of
https://github.com/thekiwismarthome/shopping-list-manager.git
synced 2026-06-30 21:46:30 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a5a0bbcaf | |||
| 40d29aee3a |
@@ -1,33 +1,135 @@
|
|||||||
|
# Shopping List Manager
|
||||||
|
|
||||||
|
A custom Home Assistant integration that provides an enhanced shopping list experience, including a companion Lovelace card for managing items directly from your dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📋 Manage shopping list items from Home Assistant
|
||||||
|
- 🔌 WebSocket-based backend (no polling entities)
|
||||||
|
- 🖥️ Custom Lovelace card
|
||||||
|
- ⚙️ UI-based configuration (Config Flow)
|
||||||
|
- 🚀 Compatible with Home Assistant **2024.8+**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. Installation (HACS)
|
## 1. Installation (HACS)
|
||||||
|
|
||||||
### Recommended
|
[](
|
||||||
|
https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager&category=integration
|
||||||
|
)
|
||||||
|
|
||||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager&category=integration)
|
Click the button above **or** follow the manual steps below.
|
||||||
|
|
||||||
1. Click the button above.
|
1. Open **HACS**
|
||||||
2. Confirm adding the repository to HACS.
|
2. Go to **Integrations**
|
||||||
3. Install **Shopping List Manager** from **HACS → Integrations**.
|
3. Click **⋮ → Custom repositories**
|
||||||
4. Restart Home Assistant.
|
4. Add this repository:
|
||||||
|
- **Repository:** `https://github.com/thekiwismarthome/shopping-list-manager`
|
||||||
|
- **Category:** Integration
|
||||||
|
5. Install **Shopping List Manager**
|
||||||
|
6. **Restart Home Assistant**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Manual Repository URL
|
## 2. Install the Lovelace Card Resource (Required)
|
||||||
|
|
||||||
https://github.com/thekiwismarthome/shopping-list-manager
|
The Lovelace card JavaScript file is included with the integration, but **must be copied manually** to the `www` directory so Home Assistant can load it.
|
||||||
|
|
||||||
Repository type: **Integration**
|
### Step 1: Copy the card file
|
||||||
|
|
||||||
|
Run the following command (via SSH, Terminal add-on, or container shell):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /config/www/community/shopping_list_card && \
|
||||||
|
cp /config/custom_components/shopping_list_manager/frontend/shopping_list_card.js \
|
||||||
|
/config/www/community/shopping_list_card/shopping_list_card.js
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Manual Installation (Optional)
|
### Step 2: Add the resource to Home Assistant
|
||||||
|
|
||||||
1. Copy the folder:
|
1. Go to **Settings → Dashboards**
|
||||||
custom_components/shopping_list_manager
|
2. Click **⋮ (top right) → Resources**
|
||||||
|
3. Click **Add Resource**
|
||||||
|
4. Enter:
|
||||||
|
|
||||||
2. Paste it into:
|
```text
|
||||||
/config/custom_components/
|
URL: /local/community/shopping_list_card/shopping_list_card.js
|
||||||
|
Type: JavaScript Module
|
||||||
|
```
|
||||||
|
|
||||||
3. Restart Home Assistant.
|
5. Click **Create**
|
||||||
|
6. Refresh your browser (**Ctrl + F5**)
|
||||||
|
|
||||||
## 3. Shopping List Card to go with this Integration
|
---
|
||||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager-card&category=plugin)
|
|
||||||
|
## 3. Add the Integration
|
||||||
|
|
||||||
|
[](
|
||||||
|
https://my.home-assistant.io/redirect/config_flow_start/?domain=shopping_list_manager
|
||||||
|
)
|
||||||
|
|
||||||
|
Click the button above **or** add it manually:
|
||||||
|
|
||||||
|
1. Go to **Settings → Devices & Services**
|
||||||
|
2. Click **Add Integration**
|
||||||
|
3. Search for **Shopping List Manager**
|
||||||
|
4. Follow the setup steps
|
||||||
|
|
||||||
|
No YAML configuration is required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Add the Card to a Dashboard
|
||||||
|
|
||||||
|
Add a **Manual** card to your dashboard and use the following YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:shopping-list-card
|
||||||
|
title: Shopping List
|
||||||
|
list_id: groceries
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the **⚙️ cog button** in the card to configure additional settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
- HACS updates will update the **integration**
|
||||||
|
- If the Lovelace card JavaScript changes in a future release, you must **repeat the copy command** above
|
||||||
|
|
||||||
|
This is expected behavior for single-repository integrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compatibility Notes
|
||||||
|
|
||||||
|
- Designed for **Home Assistant 2024.8+**
|
||||||
|
- Uses WebSocket APIs
|
||||||
|
- Fully compatible with the **Services → Actions** change introduced in Home Assistant 2024.8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If the card does not load:
|
||||||
|
|
||||||
|
1. Ensure Home Assistant was restarted after installation
|
||||||
|
2. Verify the file exists at:
|
||||||
|
|
||||||
|
```
|
||||||
|
/config/www/community/shopping_list_card/shopping_list_card.js
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Confirm the resource URL is correct
|
||||||
|
4. Perform a hard browser refresh (**Ctrl + F5**)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ from .websocket_api import (
|
|||||||
websocket_get_products,
|
websocket_get_products,
|
||||||
websocket_get_active,
|
websocket_get_active,
|
||||||
websocket_delete_product,
|
websocket_delete_product,
|
||||||
ws_get_catalogues,
|
|
||||||
ws_get_lists,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -39,10 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
ha_websocket.async_register_command(hass, websocket_get_products)
|
ha_websocket.async_register_command(hass, websocket_get_products)
|
||||||
ha_websocket.async_register_command(hass, websocket_get_active)
|
ha_websocket.async_register_command(hass, websocket_get_active)
|
||||||
ha_websocket.async_register_command(hass, websocket_delete_product)
|
ha_websocket.async_register_command(hass, websocket_delete_product)
|
||||||
ha_websocket.async_register_command(hass, ws_get_catalogues)
|
|
||||||
ha_websocket.async_register_command(hass, ws_get_lists)
|
|
||||||
|
|
||||||
_LOGGER.info("Shopping List Manager setup complete - registered 7 WebSocket commands")
|
_LOGGER.info("Shopping List Manager setup complete - registered 5 WebSocket commands")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ DOMAIN = "shopping_list_manager"
|
|||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
STORAGE_KEY_PRODUCTS = f"{DOMAIN}.products"
|
STORAGE_KEY_PRODUCTS = f"{DOMAIN}.products"
|
||||||
STORAGE_KEY_ACTIVE = f"{DOMAIN}.active_list"
|
STORAGE_KEY_ACTIVE = f"{DOMAIN}.active_list"
|
||||||
LISTS_STORE_KEY = "shopping_list_manager.lists"
|
|
||||||
|
|
||||||
# Events
|
# Events
|
||||||
EVENT_SHOPPING_LIST_UPDATED = f"{DOMAIN}_updated"
|
EVENT_SHOPPING_LIST_UPDATED = f"{DOMAIN}_updated"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,6 @@ from typing import Dict, Optional, List
|
|||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import storage
|
from homeassistant.helpers import storage
|
||||||
from homeassistant.helpers.storage import Store
|
|
||||||
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -60,20 +58,6 @@ class ShoppingListManager:
|
|||||||
self._store_active["groceries"] = storage.Store(
|
self._store_active["groceries"] = storage.Store(
|
||||||
hass, STORAGE_VERSION, STORAGE_KEY_ACTIVE # "shopping_list_manager.active_list"
|
hass, STORAGE_VERSION, STORAGE_KEY_ACTIVE # "shopping_list_manager.active_list"
|
||||||
)
|
)
|
||||||
# --- Catalogue + list metadata (NEW, additive) ---
|
|
||||||
self._store_catalogues = Store(
|
|
||||||
hass,
|
|
||||||
STORAGE_VERSION,
|
|
||||||
f"{DOMAIN}.catalogues",
|
|
||||||
)
|
|
||||||
self._catalogues: Dict[str, dict] = {}
|
|
||||||
|
|
||||||
self._store_lists = Store(
|
|
||||||
hass,
|
|
||||||
STORAGE_VERSION,
|
|
||||||
f"{DOMAIN}.lists",
|
|
||||||
)
|
|
||||||
self._lists: Dict[str, dict] = {}
|
|
||||||
|
|
||||||
def _lock_for(self, list_id: str) -> asyncio.Lock:
|
def _lock_for(self, list_id: str) -> asyncio.Lock:
|
||||||
"""Get or create lock for a list."""
|
"""Get or create lock for a list."""
|
||||||
@@ -84,21 +68,11 @@ class ShoppingListManager:
|
|||||||
def _store_products_for(self, list_id: str) -> storage.Store:
|
def _store_products_for(self, list_id: str) -> storage.Store:
|
||||||
"""Get or create products Store for a list."""
|
"""Get or create products Store for a list."""
|
||||||
if list_id not in self._store_products:
|
if list_id not in self._store_products:
|
||||||
catalogue_id = self._lists.get(list_id, {}).get("catalogue", list_id)
|
# Non-groceries lists use namespaced keys
|
||||||
catalogue = self._catalogues.get(catalogue_id)
|
key = f"{DOMAIN}.{list_id}.products"
|
||||||
|
self._store_products[list_id] = storage.Store(self.hass, STORAGE_VERSION, key)
|
||||||
key = (
|
|
||||||
catalogue["products_store"]
|
|
||||||
if catalogue
|
|
||||||
else f"{DOMAIN}.{list_id}.products"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._store_products[list_id] = storage.Store(
|
|
||||||
self.hass, STORAGE_VERSION, key
|
|
||||||
)
|
|
||||||
return self._store_products[list_id]
|
return self._store_products[list_id]
|
||||||
|
|
||||||
|
|
||||||
def _store_active_for(self, list_id: str) -> storage.Store:
|
def _store_active_for(self, list_id: str) -> storage.Store:
|
||||||
"""Get or create active Store for a list."""
|
"""Get or create active Store for a list."""
|
||||||
if list_id not in self._store_active:
|
if list_id not in self._store_active:
|
||||||
@@ -108,17 +82,6 @@ class ShoppingListManager:
|
|||||||
|
|
||||||
async def _ensure_loaded(self, list_id: str) -> None:
|
async def _ensure_loaded(self, list_id: str) -> None:
|
||||||
"""Lazily load a list from storage if not yet in memory."""
|
"""Lazily load a list from storage if not yet in memory."""
|
||||||
await self._ensure_catalogues_loaded()
|
|
||||||
await self._ensure_lists_loaded()
|
|
||||||
|
|
||||||
# Register list if it does not exist yet
|
|
||||||
if list_id not in self._lists:
|
|
||||||
self._lists[list_id] = {
|
|
||||||
"catalogue": list_id
|
|
||||||
}
|
|
||||||
await self._store_lists.async_save(self._lists)
|
|
||||||
|
|
||||||
|
|
||||||
if list_id in self._products:
|
if list_id in self._products:
|
||||||
return # already loaded
|
return # already loaded
|
||||||
|
|
||||||
@@ -140,38 +103,6 @@ class ShoppingListManager:
|
|||||||
list_id, len(self._products[list_id]), len(self._active_list[list_id])
|
list_id, len(self._products[list_id]), len(self._active_list[list_id])
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _ensure_catalogues_loaded(self) -> None:
|
|
||||||
data = await self._store_catalogues.async_load()
|
|
||||||
if isinstance(data, dict):
|
|
||||||
self._catalogues = data
|
|
||||||
return
|
|
||||||
|
|
||||||
# Bootstrap from existing behavior (no changes)
|
|
||||||
self._catalogues = {
|
|
||||||
"groceries": {
|
|
||||||
"name": "Groceries",
|
|
||||||
"icon": "🛒",
|
|
||||||
"products_store": f"{DOMAIN}.products",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await self._store_catalogues.async_save(self._catalogues)
|
|
||||||
|
|
||||||
async def _ensure_lists_loaded(self) -> None:
|
|
||||||
data = await self._store_lists.async_load()
|
|
||||||
if isinstance(data, dict):
|
|
||||||
self._lists = data
|
|
||||||
return
|
|
||||||
|
|
||||||
# Default: list_id == catalogue_id (current behavior)
|
|
||||||
self._lists = {
|
|
||||||
"groceries": {
|
|
||||||
"catalogue": "groceries",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await self._store_lists.async_save(self._lists)
|
|
||||||
|
|
||||||
async def async_load(self) -> None:
|
async def async_load(self) -> None:
|
||||||
"""Pre-load the default groceries list for backward compat."""
|
"""Pre-load the default groceries list for backward compat."""
|
||||||
async with self._lock_for("groceries"):
|
async with self._lock_for("groceries"):
|
||||||
@@ -334,15 +265,6 @@ class ShoppingListManager:
|
|||||||
_LOGGER.debug("List '%s': deleted product: %s", list_id, key)
|
_LOGGER.debug("List '%s': deleted product: %s", list_id, key)
|
||||||
self._fire_update_event()
|
self._fire_update_event()
|
||||||
|
|
||||||
def get_catalogues(self) -> Dict[str, dict]:
|
|
||||||
return self._catalogues
|
|
||||||
|
|
||||||
async def async_get_lists(self) -> Dict[str, dict]:
|
|
||||||
await self._ensure_catalogues_loaded()
|
|
||||||
await self._ensure_lists_loaded()
|
|
||||||
return self._lists
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_products(self, list_id: str) -> Dict[str, dict]:
|
async def async_get_products(self, list_id: str) -> Dict[str, dict]:
|
||||||
"""
|
"""
|
||||||
Get all products in a list's catalog.
|
Get all products in a list's catalog.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "shopping_list_manager",
|
"domain": "shopping_list_manager",
|
||||||
"name": "Shopping List Manager",
|
"name": "Shopping List Manager",
|
||||||
"version": "1.5.0",
|
"version": "1.3.0",
|
||||||
"documentation": "https://github.com/thekiwismarthome/shopping-list-manager",
|
"documentation": "https://github.com/thekiwismarthome/shopping-list-manager",
|
||||||
"issue_tracker": "https://github.com/thekiwismarthome/shopping-list-manager/issues",
|
"issue_tracker": "https://github.com/thekiwismarthome/shopping-list-manager/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
|
|||||||
@@ -74,48 +74,6 @@ async def websocket_add_product(
|
|||||||
connection.send_error(msg["id"], "add_product_failed", str(err))
|
connection.send_error(msg["id"], "add_product_failed", str(err))
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command({
|
|
||||||
vol.Required("type"): "shopping_list_manager/get_catalogues",
|
|
||||||
})
|
|
||||||
@websocket_api.async_response
|
|
||||||
async def ws_get_catalogues(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
connection: websocket_api.ActiveConnection,
|
|
||||||
msg: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Return catalogue metadata (read-only)."""
|
|
||||||
manager = hass.data[DOMAIN]["manager"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
catalogues = manager.get_catalogues()
|
|
||||||
connection.send_result(msg["id"], catalogues)
|
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.error("Error getting catalogues: %s", err)
|
|
||||||
connection.send_error(msg["id"], "get_catalogues_failed", str(err))
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command({
|
|
||||||
vol.Required("type"): "shopping_list_manager/get_lists",
|
|
||||||
})
|
|
||||||
@websocket_api.async_response
|
|
||||||
async def ws_get_lists(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
connection: websocket_api.ActiveConnection,
|
|
||||||
msg: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Return list → catalogue mapping (read-only)."""
|
|
||||||
manager = hass.data[DOMAIN]["manager"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Ensure lists are loaded
|
|
||||||
await manager._ensure_lists_loaded()
|
|
||||||
lists = manager._lists
|
|
||||||
connection.send_result(msg["id"], lists)
|
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.error("Error getting lists: %s", err)
|
|
||||||
connection.send_error(msg["id"], "get_lists_failed", str(err))
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command({
|
@websocket_api.websocket_command({
|
||||||
vol.Required("type"): "shopping_list_manager/set_qty",
|
vol.Required("type"): "shopping_list_manager/set_qty",
|
||||||
vol.Optional("list_id", default="groceries"): str,
|
vol.Optional("list_id", default="groceries"): str,
|
||||||
|
|||||||
Reference in New Issue
Block a user