mirror of
https://github.com/thekiwismarthome/shopping-list-manager.git
synced 2026-05-01 11:46:30 +00:00
Add files via upload
This commit is contained in:
@@ -1 +1,35 @@
|
|||||||
# Shopping-List-Manager
|
## Installation
|
||||||
|
|
||||||
|
### 1. Install via HACS
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager&category=integration)
|
||||||
|
|
||||||
|
Click the button above or manually add the custom repository in HACS.
|
||||||
|
Restart HA
|
||||||
|
|
||||||
|
### 2. Add the Card Resource
|
||||||
|
|
||||||
|
After installing via HACS, add the frontend resource:
|
||||||
|
|
||||||
|
1. Go to Settings → Dashboards
|
||||||
|
2. Click ⋮ (three dots, top right) → Resources
|
||||||
|
3. Click "+ Add Resource"
|
||||||
|
4. URL: `/hacsfiles/shopping-list-manager/shopping_list_card.js`
|
||||||
|
5. Resource type: JavaScript Module
|
||||||
|
6. Click "Create"
|
||||||
|
|
||||||
|
### 3. Add the Integration
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=shopping_list_manager)
|
||||||
|
|
||||||
|
Click the button above or manually add via Settings → Devices & Services.
|
||||||
|
|
||||||
|
### 4. Add Card to Dashboard
|
||||||
|
```yaml
|
||||||
|
type: custom:shopping-list-card
|
||||||
|
title: Shopping List
|
||||||
|
list_id: groceries
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Use the ⚙️ cog button in the card to configure settings.
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
Shopping List Manager - Home Assistant Custom Integration
|
||||||
|
Clean-slate architecture with enforced invariants
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.components import websocket_api
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .manager import ShoppingListManager
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
|
"""Set up the Shopping List Manager component."""
|
||||||
|
# Register frontend path for the card
|
||||||
|
frontend_path = Path(__file__).parent / "frontend"
|
||||||
|
hass.http.register_static_path(
|
||||||
|
f"/hacsfiles/{DOMAIN}",
|
||||||
|
str(frontend_path),
|
||||||
|
cache_headers=False,
|
||||||
|
)
|
||||||
|
_LOGGER.info(f"Registered frontend path: /hacsfiles/{DOMAIN}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Shopping List Manager from a config entry."""
|
||||||
|
# Initialize the manager
|
||||||
|
manager = ShoppingListManager(hass)
|
||||||
|
await manager.async_load()
|
||||||
|
|
||||||
|
# Store manager in hass.data
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
hass.data[DOMAIN]["manager"] = manager
|
||||||
|
|
||||||
|
# Register WebSocket commands
|
||||||
|
from .websocket_api import (
|
||||||
|
websocket_add_product,
|
||||||
|
websocket_set_qty,
|
||||||
|
websocket_get_products,
|
||||||
|
websocket_get_active,
|
||||||
|
websocket_delete_product,
|
||||||
|
)
|
||||||
|
|
||||||
|
websocket_api.async_register_command(hass, websocket_add_product)
|
||||||
|
websocket_api.async_register_command(hass, websocket_set_qty)
|
||||||
|
websocket_api.async_register_command(hass, websocket_get_products)
|
||||||
|
websocket_api.async_register_command(hass, websocket_get_active)
|
||||||
|
websocket_api.async_register_command(hass, websocket_delete_product)
|
||||||
|
|
||||||
|
_LOGGER.info("Shopping List Manager setup complete - registered 5 WebSocket commands")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload Shopping List Manager."""
|
||||||
|
hass.data[DOMAIN].pop("manager", None)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Config flow for Shopping List Manager."""
|
||||||
|
from homeassistant import config_entries
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingListManagerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Shopping List Manager."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
"""Handle the initial step."""
|
||||||
|
# Only allow one instance
|
||||||
|
if self._async_current_entries():
|
||||||
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="Shopping List Manager",
|
||||||
|
data={}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show simple form
|
||||||
|
return self.async_show_form(step_id="user")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Constants for Shopping List Manager."""
|
||||||
|
|
||||||
|
DOMAIN = "shopping_list_manager"
|
||||||
|
|
||||||
|
# Storage keys
|
||||||
|
STORAGE_VERSION = 1
|
||||||
|
STORAGE_KEY_PRODUCTS = f"{DOMAIN}.products"
|
||||||
|
STORAGE_KEY_ACTIVE = f"{DOMAIN}.active_list"
|
||||||
|
|
||||||
|
# Events
|
||||||
|
EVENT_SHOPPING_LIST_UPDATED = f"{DOMAIN}_updated"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
|||||||
|
"""Core Shopping List Manager with invariant enforcement."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Optional, List
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import storage
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
EVENT_SHOPPING_LIST_UPDATED,
|
||||||
|
STORAGE_KEY_ACTIVE,
|
||||||
|
STORAGE_KEY_PRODUCTS,
|
||||||
|
STORAGE_VERSION,
|
||||||
|
)
|
||||||
|
from .models import Product, ActiveItem, InvariantError, validate_invariant
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingListManager:
|
||||||
|
"""
|
||||||
|
Manages multiple independent shopping lists.
|
||||||
|
|
||||||
|
Each list_id gets its own pair of storage files:
|
||||||
|
- shopping_list_manager.{list_id}.products
|
||||||
|
- shopping_list_manager.{list_id}.active_list
|
||||||
|
|
||||||
|
The default "groceries" list uses the original flat keys for backward compat:
|
||||||
|
- shopping_list_manager.products → groceries products
|
||||||
|
- shopping_list_manager.active_list → groceries active
|
||||||
|
|
||||||
|
Architecture principles:
|
||||||
|
1. Products and active_list are separate concerns per list
|
||||||
|
2. Products are authoritative, persistent data
|
||||||
|
3. Active list is ephemeral state
|
||||||
|
4. Invariant (active ⊆ products) enforced on every mutation
|
||||||
|
5. Lock ensures atomic operations per list
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant):
|
||||||
|
"""Initialize the manager."""
|
||||||
|
self.hass = hass
|
||||||
|
# Per-list in-memory caches: list_id -> {key: Product}
|
||||||
|
self._products: Dict[str, Dict[str, Product]] = {}
|
||||||
|
self._active_list: Dict[str, Dict[str, ActiveItem]] = {}
|
||||||
|
# Per-list locks
|
||||||
|
self._locks: Dict[str, asyncio.Lock] = {}
|
||||||
|
# Per-list storage Store instances (created lazily, except groceries)
|
||||||
|
self._store_products: Dict[str, storage.Store] = {}
|
||||||
|
self._store_active: Dict[str, storage.Store] = {}
|
||||||
|
|
||||||
|
# Pre-create stores for the default "groceries" list using the original flat keys
|
||||||
|
# for backward compatibility — existing data just works
|
||||||
|
self._store_products["groceries"] = storage.Store(
|
||||||
|
hass, STORAGE_VERSION, STORAGE_KEY_PRODUCTS # "shopping_list_manager.products"
|
||||||
|
)
|
||||||
|
self._store_active["groceries"] = storage.Store(
|
||||||
|
hass, STORAGE_VERSION, STORAGE_KEY_ACTIVE # "shopping_list_manager.active_list"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _lock_for(self, list_id: str) -> asyncio.Lock:
|
||||||
|
"""Get or create lock for a list."""
|
||||||
|
if list_id not in self._locks:
|
||||||
|
self._locks[list_id] = asyncio.Lock()
|
||||||
|
return self._locks[list_id]
|
||||||
|
|
||||||
|
def _store_products_for(self, list_id: str) -> storage.Store:
|
||||||
|
"""Get or create products Store for a list."""
|
||||||
|
if list_id not in self._store_products:
|
||||||
|
# Non-groceries lists use namespaced keys
|
||||||
|
key = f"{DOMAIN}.{list_id}.products"
|
||||||
|
self._store_products[list_id] = storage.Store(self.hass, STORAGE_VERSION, key)
|
||||||
|
return self._store_products[list_id]
|
||||||
|
|
||||||
|
def _store_active_for(self, list_id: str) -> storage.Store:
|
||||||
|
"""Get or create active Store for a list."""
|
||||||
|
if list_id not in self._store_active:
|
||||||
|
key = f"{DOMAIN}.{list_id}.active_list"
|
||||||
|
self._store_active[list_id] = storage.Store(self.hass, STORAGE_VERSION, key)
|
||||||
|
return self._store_active[list_id]
|
||||||
|
|
||||||
|
async def _ensure_loaded(self, list_id: str) -> None:
|
||||||
|
"""Lazily load a list from storage if not yet in memory."""
|
||||||
|
if list_id in self._products:
|
||||||
|
return # already loaded
|
||||||
|
|
||||||
|
products_data = await self._store_products_for(list_id).async_load()
|
||||||
|
self._products[list_id] = {
|
||||||
|
key: Product.from_dict(data) for key, data in (products_data or {}).items()
|
||||||
|
}
|
||||||
|
|
||||||
|
active_data = await self._store_active_for(list_id).async_load()
|
||||||
|
self._active_list[list_id] = {
|
||||||
|
key: ActiveItem.from_dict(data) for key, data in (active_data or {}).items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Repair any orphaned active items
|
||||||
|
await self._async_repair_invariant(list_id)
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"Loaded list '%s': %d products, %d active",
|
||||||
|
list_id, len(self._products[list_id]), len(self._active_list[list_id])
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_load(self) -> None:
|
||||||
|
"""Pre-load the default groceries list for backward compat."""
|
||||||
|
async with self._lock_for("groceries"):
|
||||||
|
await self._ensure_loaded("groceries")
|
||||||
|
|
||||||
|
async def _async_repair_invariant(self, list_id: str) -> None:
|
||||||
|
"""Remove active items whose product no longer exists."""
|
||||||
|
orphaned = [k for k in self._active_list[list_id] if k not in self._products[list_id]]
|
||||||
|
if orphaned:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"List '%s': removing %d orphaned active items: %s",
|
||||||
|
list_id, len(orphaned), orphaned
|
||||||
|
)
|
||||||
|
for k in orphaned:
|
||||||
|
del self._active_list[list_id][k]
|
||||||
|
await self._async_save_active(list_id)
|
||||||
|
|
||||||
|
async def _async_save_products(self, list_id: str) -> None:
|
||||||
|
"""Persist products to storage."""
|
||||||
|
data = {key: p.to_dict() for key, p in self._products[list_id].items()}
|
||||||
|
await self._store_products_for(list_id).async_save(data)
|
||||||
|
|
||||||
|
async def _async_save_active(self, list_id: str) -> None:
|
||||||
|
"""Persist active list to storage."""
|
||||||
|
data = {key: a.to_dict() for key, a in self._active_list[list_id].items()}
|
||||||
|
await self._store_active_for(list_id).async_save(data)
|
||||||
|
|
||||||
|
def _fire_update_event(self) -> None:
|
||||||
|
"""Fire event to notify listeners of changes."""
|
||||||
|
self.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# PUBLIC API - All operations enforce invariants
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def async_add_product(
|
||||||
|
self,
|
||||||
|
list_id: str,
|
||||||
|
key: str,
|
||||||
|
name: str,
|
||||||
|
category: str = "other",
|
||||||
|
unit: str = "pcs",
|
||||||
|
image: str = ""
|
||||||
|
) -> Product:
|
||||||
|
"""
|
||||||
|
Add or update a product in a list's catalog.
|
||||||
|
|
||||||
|
This operation:
|
||||||
|
- Creates/updates product metadata
|
||||||
|
- Does NOT modify quantities
|
||||||
|
- Is idempotent
|
||||||
|
- Persists to storage
|
||||||
|
|
||||||
|
Args:
|
||||||
|
list_id: List identifier
|
||||||
|
key: Unique product identifier
|
||||||
|
name: Display name
|
||||||
|
category: Product category
|
||||||
|
unit: Unit of measurement
|
||||||
|
image: Image URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created/updated Product
|
||||||
|
"""
|
||||||
|
async with self._lock_for(list_id):
|
||||||
|
await self._ensure_loaded(list_id)
|
||||||
|
|
||||||
|
product = Product(
|
||||||
|
key=key,
|
||||||
|
name=name,
|
||||||
|
category=category,
|
||||||
|
unit=unit,
|
||||||
|
image=image
|
||||||
|
)
|
||||||
|
|
||||||
|
self._products[list_id][key] = product
|
||||||
|
await self._async_save_products(list_id)
|
||||||
|
|
||||||
|
_LOGGER.debug("List '%s': added/updated product %s (%s)", list_id, name, key)
|
||||||
|
self._fire_update_event()
|
||||||
|
|
||||||
|
return product
|
||||||
|
|
||||||
|
async def async_set_qty(self, list_id: str, key: str, qty: int) -> None:
|
||||||
|
"""
|
||||||
|
Set quantity for a product on the shopping list.
|
||||||
|
|
||||||
|
This operation:
|
||||||
|
- REQUIRES product to exist (enforces invariant)
|
||||||
|
- qty > 0: adds/updates active_list
|
||||||
|
- qty == 0: removes from active_list
|
||||||
|
- Persists state
|
||||||
|
- Fires update event
|
||||||
|
|
||||||
|
Args:
|
||||||
|
list_id: List identifier
|
||||||
|
key: Product key (must exist in catalog)
|
||||||
|
qty: New quantity (0 to remove, >0 to add/update)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvariantError: If product doesn't exist
|
||||||
|
ValueError: If qty is negative
|
||||||
|
"""
|
||||||
|
if qty < 0:
|
||||||
|
raise ValueError(f"Quantity cannot be negative: {qty}")
|
||||||
|
|
||||||
|
async with self._lock_for(list_id):
|
||||||
|
await self._ensure_loaded(list_id)
|
||||||
|
|
||||||
|
# INVARIANT ENFORCEMENT: Product must exist
|
||||||
|
if key not in self._products[list_id]:
|
||||||
|
raise InvariantError(
|
||||||
|
f"Cannot set quantity for unknown product '{key}' in list '{list_id}'. "
|
||||||
|
f"Product must be created first with add_product."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update or remove from active list
|
||||||
|
if qty > 0:
|
||||||
|
self._active_list[list_id][key] = ActiveItem(qty=qty)
|
||||||
|
_LOGGER.debug("List '%s': set qty for %s: %d", list_id, key, qty)
|
||||||
|
else:
|
||||||
|
# qty == 0: remove from list
|
||||||
|
if key in self._active_list[list_id]:
|
||||||
|
del self._active_list[list_id][key]
|
||||||
|
_LOGGER.debug("List '%s': removed %s from active list", list_id, key)
|
||||||
|
|
||||||
|
await self._async_save_active(list_id)
|
||||||
|
self._fire_update_event()
|
||||||
|
|
||||||
|
async def async_delete_product(self, list_id: str, key: str) -> None:
|
||||||
|
"""
|
||||||
|
Delete a product from the catalog.
|
||||||
|
|
||||||
|
This operation:
|
||||||
|
- Removes product from catalog
|
||||||
|
- Removes from active list (maintains invariant)
|
||||||
|
- Persists both changes
|
||||||
|
|
||||||
|
Args:
|
||||||
|
list_id: List identifier
|
||||||
|
key: Product key to delete
|
||||||
|
"""
|
||||||
|
async with self._lock_for(list_id):
|
||||||
|
await self._ensure_loaded(list_id)
|
||||||
|
|
||||||
|
if key not in self._products[list_id]:
|
||||||
|
_LOGGER.warning("List '%s': attempted to delete non-existent product: %s", list_id, key)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove from catalog
|
||||||
|
del self._products[list_id][key]
|
||||||
|
|
||||||
|
# Remove from active list (maintain invariant)
|
||||||
|
if key in self._active_list[list_id]:
|
||||||
|
del self._active_list[list_id][key]
|
||||||
|
|
||||||
|
await self._async_save_products(list_id)
|
||||||
|
await self._async_save_active(list_id)
|
||||||
|
|
||||||
|
_LOGGER.debug("List '%s': deleted product: %s", list_id, key)
|
||||||
|
self._fire_update_event()
|
||||||
|
|
||||||
|
async def async_get_products(self, list_id: str) -> Dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Get all products in a list's catalog.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
list_id: List identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of product key -> product data
|
||||||
|
"""
|
||||||
|
async with self._lock_for(list_id):
|
||||||
|
await self._ensure_loaded(list_id)
|
||||||
|
return {key: product.to_dict() for key, product in self._products[list_id].items()}
|
||||||
|
|
||||||
|
async def async_get_active(self, list_id: str) -> Dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Get active shopping list (quantities only).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
list_id: List identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of product key -> active item data (qty only)
|
||||||
|
"""
|
||||||
|
async with self._lock_for(list_id):
|
||||||
|
await self._ensure_loaded(list_id)
|
||||||
|
return {key: item.to_dict() for key, item in self._active_list[list_id].items()}
|
||||||
|
|
||||||
|
# NOTE: The following methods were removed as they're not used by the websocket API
|
||||||
|
# and would need updating to support per-list structure:
|
||||||
|
# - async_get_full_state()
|
||||||
|
# - get_product()
|
||||||
|
# - get_active_qty()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"domain": "shopping_list_manager",
|
||||||
|
"name": "Shopping List Manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"documentation": "https://github.com/thekiwismarthome/shopping-list-manager",
|
||||||
|
"issue_tracker": "https://github.com/thekiwismarthome/shopping-list-manager/issues",
|
||||||
|
"requirements": [],
|
||||||
|
"dependencies": [],
|
||||||
|
"codeowners": ["@thekiwismarthome"],
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling"
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Data models for Shopping List Manager."""
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Product:
|
||||||
|
"""
|
||||||
|
Product catalog entry - authoritative product definition.
|
||||||
|
|
||||||
|
Products exist independently of the shopping list.
|
||||||
|
They define WHAT can be shopped, not HOW MUCH is needed.
|
||||||
|
"""
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
category: str = "other"
|
||||||
|
unit: str = "pcs"
|
||||||
|
image: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for storage/transmission."""
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"name": self.name,
|
||||||
|
"category": self.category,
|
||||||
|
"unit": self.unit,
|
||||||
|
"image": self.image
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> 'Product':
|
||||||
|
"""Create Product from dictionary."""
|
||||||
|
return Product(
|
||||||
|
key=data["key"],
|
||||||
|
name=data["name"],
|
||||||
|
category=data.get("category", "other"),
|
||||||
|
unit=data.get("unit", "pcs"),
|
||||||
|
image=data.get("image", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Validate product data."""
|
||||||
|
if not self.key:
|
||||||
|
raise ValueError("Product key cannot be empty")
|
||||||
|
if not self.name:
|
||||||
|
raise ValueError("Product name cannot be empty")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActiveItem:
|
||||||
|
"""
|
||||||
|
Shopping list state - quantity only.
|
||||||
|
|
||||||
|
Contains NO product metadata, only references products by key.
|
||||||
|
qty > 0 means "on the list"
|
||||||
|
qty == 0 means "not on the list" (should be removed)
|
||||||
|
"""
|
||||||
|
qty: int
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for storage/transmission."""
|
||||||
|
return {"qty": self.qty}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> 'ActiveItem':
|
||||||
|
"""Create ActiveItem from dictionary."""
|
||||||
|
return ActiveItem(qty=data["qty"])
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Validate quantity."""
|
||||||
|
if self.qty < 0:
|
||||||
|
raise ValueError("Quantity cannot be negative")
|
||||||
|
|
||||||
|
|
||||||
|
class InvariantError(Exception):
|
||||||
|
"""
|
||||||
|
Raised when the core data model invariant is violated.
|
||||||
|
|
||||||
|
Invariant: Every key in active_list MUST exist in products.
|
||||||
|
|
||||||
|
If this exception is raised, the system is in an inconsistent state
|
||||||
|
and must be repaired before continuing.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def validate_invariant(products: Dict[str, Product],
|
||||||
|
active_list: Dict[str, ActiveItem]) -> None:
|
||||||
|
"""
|
||||||
|
Validate the core data model invariant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
products: Product catalog dictionary
|
||||||
|
active_list: Active shopping list dictionary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvariantError: If any key in active_list doesn't exist in products
|
||||||
|
"""
|
||||||
|
for key in active_list:
|
||||||
|
if key not in products:
|
||||||
|
raise InvariantError(
|
||||||
|
f"Invariant violated: active_list contains unknown product key '{key}'. "
|
||||||
|
f"This product must be added to the catalog first."
|
||||||
|
)
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""WebSocket API for Shopping List Manager."""
|
||||||
|
import logging
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import websocket_api
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .models import InvariantError
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command({
|
||||||
|
vol.Required("type"): "shopping_list_manager/add_product",
|
||||||
|
vol.Optional("list_id", default="groceries"): str,
|
||||||
|
vol.Required("key"): str,
|
||||||
|
vol.Required("name"): str,
|
||||||
|
vol.Optional("category", default="other"): str,
|
||||||
|
vol.Optional("unit", default="pcs"): str,
|
||||||
|
vol.Optional("image", default=""): str,
|
||||||
|
})
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def websocket_add_product(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add or update a product in the catalog.
|
||||||
|
|
||||||
|
Does NOT modify quantity - use set_qty for that.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"type": "shopping_list_manager/add_product",
|
||||||
|
"list_id": "groceries", # optional, defaults to "groceries"
|
||||||
|
"key": "milk",
|
||||||
|
"name": "Milk",
|
||||||
|
"category": "dairy",
|
||||||
|
"unit": "pcs",
|
||||||
|
"image": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": {
|
||||||
|
"key": "milk",
|
||||||
|
"name": "Milk",
|
||||||
|
"category": "dairy",
|
||||||
|
"unit": "pcs",
|
||||||
|
"image": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
manager = hass.data[DOMAIN]["manager"]
|
||||||
|
list_id = msg.get("list_id", "groceries")
|
||||||
|
|
||||||
|
try:
|
||||||
|
product = await manager.async_add_product(
|
||||||
|
list_id=list_id,
|
||||||
|
key=msg["key"],
|
||||||
|
name=msg["name"],
|
||||||
|
category=msg.get("category", "other"),
|
||||||
|
unit=msg.get("unit", "pcs"),
|
||||||
|
image=msg.get("image", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.send_result(msg["id"], product.to_dict())
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error adding product to list '%s': %s", list_id, err)
|
||||||
|
connection.send_error(msg["id"], "add_product_failed", str(err))
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command({
|
||||||
|
vol.Required("type"): "shopping_list_manager/set_qty",
|
||||||
|
vol.Optional("list_id", default="groceries"): str,
|
||||||
|
vol.Required("key"): str,
|
||||||
|
vol.Required("qty"): vol.All(int, vol.Range(min=0)),
|
||||||
|
})
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def websocket_set_qty(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Set quantity for a product on the shopping list.
|
||||||
|
|
||||||
|
Product MUST exist in catalog first.
|
||||||
|
qty = 0 removes from list.
|
||||||
|
qty > 0 adds/updates on list.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"type": "shopping_list_manager/set_qty",
|
||||||
|
"list_id": "groceries", # optional, defaults to "groceries"
|
||||||
|
"key": "milk",
|
||||||
|
"qty": 2
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
|
||||||
|
Error (if product doesn't exist):
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "invariant_violation",
|
||||||
|
"message": "Cannot set quantity for unknown product 'milk'..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
manager = hass.data[DOMAIN]["manager"]
|
||||||
|
list_id = msg.get("list_id", "groceries")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await manager.async_set_qty(
|
||||||
|
list_id=list_id,
|
||||||
|
key=msg["key"],
|
||||||
|
qty=msg["qty"]
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.send_result(msg["id"], {"success": True})
|
||||||
|
|
||||||
|
except InvariantError as err:
|
||||||
|
# This is expected if frontend tries to set qty for non-existent product
|
||||||
|
_LOGGER.warning("Invariant violation in set_qty (list '%s'): %s", list_id, err)
|
||||||
|
connection.send_error(msg["id"], "invariant_violation", str(err))
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error setting quantity in list '%s': %s", list_id, err)
|
||||||
|
connection.send_error(msg["id"], "set_qty_failed", str(err))
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command({
|
||||||
|
vol.Required("type"): "shopping_list_manager/get_products",
|
||||||
|
vol.Optional("list_id", default="groceries"): str,
|
||||||
|
})
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def websocket_get_products(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Get all products in the catalog.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"type": "shopping_list_manager/get_products",
|
||||||
|
"list_id": "groceries" # optional, defaults to "groceries"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"milk": {
|
||||||
|
"key": "milk",
|
||||||
|
"name": "Milk",
|
||||||
|
"category": "dairy",
|
||||||
|
"unit": "pcs",
|
||||||
|
"image": ""
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
manager = hass.data[DOMAIN]["manager"]
|
||||||
|
list_id = msg.get("list_id", "groceries")
|
||||||
|
|
||||||
|
try:
|
||||||
|
products = await manager.async_get_products(list_id=list_id)
|
||||||
|
connection.send_result(msg["id"], products)
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error getting products for list '%s': %s", list_id, err)
|
||||||
|
connection.send_error(msg["id"], "get_products_failed", str(err))
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command({
|
||||||
|
vol.Required("type"): "shopping_list_manager/get_active",
|
||||||
|
vol.Optional("list_id", default="groceries"): str,
|
||||||
|
})
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def websocket_get_active(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Get active shopping list (quantities only).
|
||||||
|
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"type": "shopping_list_manager/get_active",
|
||||||
|
"list_id": "groceries" # optional, defaults to "groceries"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"milk": {"qty": 2},
|
||||||
|
"bread": {"qty": 1},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
manager = hass.data[DOMAIN]["manager"]
|
||||||
|
list_id = msg.get("list_id", "groceries")
|
||||||
|
|
||||||
|
try:
|
||||||
|
active = await manager.async_get_active(list_id=list_id)
|
||||||
|
connection.send_result(msg["id"], active)
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error getting active list for '%s': %s", list_id, err)
|
||||||
|
connection.send_error(msg["id"], "get_active_failed", str(err))
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command({
|
||||||
|
vol.Required("type"): "shopping_list_manager/delete_product",
|
||||||
|
vol.Optional("list_id", default="groceries"): str,
|
||||||
|
vol.Required("key"): str,
|
||||||
|
})
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def websocket_delete_product(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Delete a product from catalog (and remove from active list).
|
||||||
|
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"type": "shopping_list_manager/delete_product",
|
||||||
|
"list_id": "groceries", # optional, defaults to "groceries"
|
||||||
|
"key": "milk"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
manager = hass.data[DOMAIN]["manager"]
|
||||||
|
list_id = msg.get("list_id", "groceries")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await manager.async_delete_product(list_id=list_id, key=msg["key"])
|
||||||
|
connection.send_result(msg["id"], {"success": True})
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error deleting product from list '%s': %s", list_id, err)
|
||||||
|
connection.send_error(msg["id"], "delete_product_failed", str(err))
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "Shopping List Manager",
|
||||||
|
"content_in_root": false,
|
||||||
|
"render_readme": true,
|
||||||
|
"domains": ["shopping_list_manager"],
|
||||||
|
"homeassistant": "2024.1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Shopping List Manager
|
||||||
|
|
||||||
|
A modern shopping list integration with visual tiles, auto-image search, and multi-list support.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Visual grid/list layouts
|
||||||
|
- Auto-image search from local files
|
||||||
|
- Multiple independent lists
|
||||||
|
- Cog settings for quick configuration
|
||||||
|
- Category organization
|
||||||
|
- Haptic feedback
|
||||||
Reference in New Issue
Block a user