Add files via upload

This commit is contained in:
thekiwismarthome
2026-02-05 23:39:05 +13:00
committed by GitHub
parent 3160c75305
commit a89129b284
11 changed files with 3027 additions and 1 deletions
+35 -1
View File
@@ -1 +1,35 @@
# Shopping-List-Manager ## Installation
### 1. Install via HACS
[![Open your Home Assistant instance and open a 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)
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
[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](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))
+7
View File
@@ -0,0 +1,7 @@
{
"name": "Shopping List Manager",
"content_in_root": false,
"render_readme": true,
"domains": ["shopping_list_manager"],
"homeassistant": "2024.1.0"
}
+11
View File
@@ -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