fix: Resolve recipe/button form submission and add comprehensive debug logging

- Fix form parameter handling for recipe_id (empty string vs None)
- Remove interfering hx-on attributes from HTMX forms
- Add explicit hx-swap="innerHTML" for proper content replacement
- Implement global exception handler with detailed DEBUG logging
- Add request middleware for comprehensive request/response logging
- Enhanced modal closing logic with fallbacks
- Add debug logging to recipe/button creation endpoints
- Create error template for better error display
- Update CLI to support --log-level per subcommand
- Add CLAUDE.md with development guidelines and conventions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Marc Boivin 2025-06-20 06:27:47 -04:00
parent 388ad4f5a0
commit 087abca062
13 changed files with 431 additions and 17 deletions

188
CLAUDE.md Normal file
View File

@ -0,0 +1,188 @@
# Claude Development Guide for ECM Control
This file contains important information for Claude Code when working on the ECM Control project.
## Project Overview
ECM Control is an Espresso Coffee Machine Control System built with:
- **Backend**: Python, FastAPI, SQLite
- **Frontend**: HTMX, Bootstrap, Vanilla JavaScript
- **Hardware**: Raspberry Pi GPIO, relay control, Acaia scale integration
- **Package Manager**: UV (not pip/poetry)
- **Database**: SQLite with schema in `src/ecm_control/database/schema.sql`
## Package Management - IMPORTANT
**Always use UV, never use pip or poetry:**
```bash
# Install dependencies
uv add package-name
# Remove dependencies
uv remove package-name
# Run commands in virtual environment
uv run python script.py
# Sync dependencies
uv sync
```
## Running the Application
**Web Application:**
```bash
uv run python run.py web --log-level DEBUG --port 8000
```
**GPIO Control Service:**
```bash
# With actual hardware
uv run python run.py gpio
# With mock hardware (for development)
uv run python run.py gpio --mock --log-level DEBUG
```
**Database Management:**
```bash
# Initialize database
uv run python run.py db init
# Reset database (deletes all data)
uv run python run.py db reset
```
## Development Commands
**Testing endpoints:**
- Web interface: http://localhost:8000
- Debug with console logging enabled in browser
- Server logs show detailed request/response info at DEBUG level
**Database queries for debugging:**
```bash
uv run python -c "
import sys; sys.path.insert(0, 'src')
from ecm_control.database import DatabaseManager
db = DatabaseManager()
recipes = db.execute_query('SELECT * FROM recipes')
print(recipes)
"
```
## Code Style and Conventions
**File Structure:**
- `src/ecm_control/` - Main package
- `templates/` - Jinja2 HTML templates
- `static/` - CSS/JS assets
- `run.py` - Main entry point (use this instead of module execution)
**Logging:**
- Use `from ecm_control.utils.logging_config import get_logger`
- Logger name: `logger = get_logger('module_name')`
- DEBUG level shows detailed request/exception info
**Database:**
- Use `DatabaseManager` class for all DB operations
- Schema in `src/ecm_control/database/schema.sql`
- Models in `src/ecm_control/models/__init__.py`
## Git Workflow
**When making changes:**
```bash
git add .
git commit -m "Description of changes
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>"
```
**Commit message format:**
- Use conventional commits (feat:, fix:, docs:, etc.)
- Include the Claude Code footer
- Be descriptive about what changed and why
## Testing and Deployment
**Before committing:**
1. Test web interface functionality
2. Test GPIO service in mock mode
3. Verify database operations work
4. Check logs for errors/warnings
**Hardware Testing:**
- Use `--mock` flag for development without GPIO hardware
- Real hardware requires Raspberry Pi with GPIO pins
- Scale integration uses mock implementation (pyacaia had build issues)
**Production Deployment:**
- Copy `ecm-control.service` to `/etc/systemd/system/`
- Update paths in service file for your installation
- Use `systemctl` to manage the service
## Common Issues and Solutions
**"No module named ecm_control":**
- Use `uv run python run.py` instead of `uv run python -m ecm_control`
**HTMX not updating UI:**
- Check browser console for JavaScript errors
- Verify `hx-target` selectors match element IDs
- Ensure `hx-swap="innerHTML"` is specified for content replacement
**Database errors:**
- Reset database: `uv run python run.py db reset`
- Check file permissions on `ecm_control.db`
**GPIO permissions (on Raspberry Pi):**
```bash
sudo usermod -a -G gpio $USER
# Then log out and back in
```
**Form validation errors (422):**
- Check FastAPI endpoint parameter types
- Handle empty strings vs None for optional fields
- Use `str = Form("")` and convert to int/None in endpoint
## Architecture Notes
**Web Application:**
- FastAPI with dependency injection
- HTMX for dynamic UI updates
- Bootstrap for styling
- Jinja2 templates with partials for HTMX responses
**GPIO Control:**
- Async event-driven architecture
- Mock implementations for development
- Real-time shot monitoring with scale feedback
- Database logging of all shot attempts
**Database Schema:**
- `recipes` - extraction recipes (grams + timeout)
- `buttons` - physical GPIO buttons mapped to recipes
- `shots` - log of all extraction attempts
- `settings` - configurable system parameters
## File Locations
**Key configuration files:**
- `pyproject.toml` - Project dependencies and metadata
- `ecm-control.service` - Systemd service configuration
- `src/ecm_control/database/schema.sql` - Database schema
- `src/ecm_control/web/__init__.py` - FastAPI web application
- `src/ecm_control/gpio/__init__.py` - GPIO control logic
**Templates:**
- `templates/base.html` - Base template with navigation
- `templates/partials/` - HTMX response templates
- `static/css/style.css` - Custom styles
- `static/js/app.js` - Frontend JavaScript
Remember: This is a hardware control system, so safety and reliability are important. Always test thoroughly and handle errors gracefully.

BIN
README.md

Binary file not shown.

BIN
ecm_control.db Normal file

Binary file not shown.

0
logs/performance.log Normal file
View File

View File

@ -1,8 +1,7 @@
[project]
name = "ecm-control"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
description = "Espresso Coffee Machine Control System"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115.13",
@ -11,3 +10,13 @@ dependencies = [
"python-multipart>=0.0.20",
"uvicorn>=0.34.3",
]
[project.scripts]
ecm-control = "ecm_control.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/ecm_control"]

16
run.py Executable file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""
Simple runner script for ECM Control
"""
import sys
import os
# Add src to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
# Import and run the main function
from ecm_control.__main__ import main
if __name__ == '__main__':
sys.exit(main())

View File

@ -24,24 +24,30 @@ def main():
web_parser.add_argument('--host', default='0.0.0.0', help='Host to bind to')
web_parser.add_argument('--port', default=8000, type=int, help='Port to bind to')
web_parser.add_argument('--reload', action='store_true', help='Enable auto-reload')
web_parser.add_argument('--log-level', default='INFO',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
help='Logging level')
web_parser.add_argument('--log-file', help='Log file path')
# GPIO control command
gpio_parser = subparsers.add_parser('gpio', help='Start GPIO control service')
gpio_parser.add_argument('--mock', action='store_true', help='Use mock GPIO for testing')
gpio_parser.add_argument('--log-level', default='INFO',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
help='Logging level')
gpio_parser.add_argument('--log-file', help='Log file path')
# Database management commands
db_parser = subparsers.add_parser('db', help='Database management')
db_parser.add_argument('--log-level', default='INFO',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
help='Logging level')
db_parser.add_argument('--log-file', help='Log file path')
db_subparsers = db_parser.add_subparsers(dest='db_command')
db_subparsers.add_parser('init', help='Initialize database')
db_subparsers.add_parser('reset', help='Reset database (WARNING: deletes all data)')
# Global options
parser.add_argument('--log-level', default='INFO',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
help='Logging level')
parser.add_argument('--log-file', help='Log file path')
args = parser.parse_args()
# Setup logging
@ -83,11 +89,15 @@ def run_web_server(args):
logger = get_logger('web')
logger.info(f"Starting web server on {args.host}:{args.port}")
# Set uvicorn log level based on our log level
uvicorn_log_level = "debug" if args.log_level.upper() == "DEBUG" else "info"
uvicorn.run(
"ecm_control.web:app",
host=args.host,
port=args.port,
reload=args.reload
reload=args.reload,
log_level=uvicorn_log_level
)
return 0

View File

@ -1,9 +1,10 @@
from fastapi import FastAPI, Request, Depends, HTTPException, Form
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from typing import List, Optional
import logging
import traceback
from pathlib import Path
from ..database import DatabaseManager
@ -13,6 +14,107 @@ logger = logging.getLogger(__name__)
app = FastAPI(title="ECM Control", description="Espresso Coffee Machine Control System")
# Global exception handler for uncaught exceptions
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Handle all uncaught exceptions with detailed logging when DEBUG is enabled"""
# Get current log level
current_level = logger.getEffectiveLevel()
if current_level == logging.DEBUG:
# Detailed logging for DEBUG level
logger.error(f"Uncaught exception in {request.method} {request.url}")
logger.error(f"Exception type: {type(exc).__name__}")
logger.error(f"Exception message: {str(exc)}")
logger.error(f"Request headers: {dict(request.headers)}")
# Log query parameters if any
if request.query_params:
logger.error(f"Query parameters: {dict(request.query_params)}")
# Log path parameters if any
if hasattr(request, 'path_params') and request.path_params:
logger.error(f"Path parameters: {request.path_params}")
# Log full stack trace
logger.error(f"Full traceback:\n{traceback.format_exc()}")
# Try to log request body for POST/PUT requests (be careful with sensitive data)
if request.method in ["POST", "PUT", "PATCH"]:
try:
# This is a simplified approach - in production you'd want to be more careful
logger.error("Request body logging attempted (not implemented for security)")
except Exception as e:
logger.error(f"Could not log request body: {e}")
else:
# Standard error logging for other levels
logger.error(f"Uncaught exception in {request.method} {request.url}: {type(exc).__name__}: {str(exc)}")
# Return appropriate response based on request type
if request.url.path.startswith("/api/") or "application/json" in request.headers.get("accept", ""):
# Return JSON response for API calls
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"detail": str(exc) if current_level == logging.DEBUG else "An unexpected error occurred"
}
)
else:
# Return HTML response for web requests
try:
return templates.TemplateResponse("error.html", {
"request": request,
"error_message": "An unexpected error occurred. Please try again.",
"error_detail": str(exc) if current_level == logging.DEBUG else None,
"status_code": 500
}, status_code=500)
except Exception:
# Fallback if template rendering fails
return HTMLResponse(
content=f"""
<html>
<head><title>Error</title></head>
<body>
<h1>Internal Server Error</h1>
<p>An unexpected error occurred. Please try again.</p>
{f'<pre>{str(exc)}</pre>' if current_level == logging.DEBUG else ''}
</body>
</html>
""",
status_code=500
)
# Request logging middleware for DEBUG level
@app.middleware("http")
async def debug_logging_middleware(request: Request, call_next):
"""Log all requests when DEBUG level is enabled"""
current_level = logger.getEffectiveLevel()
if current_level == logging.DEBUG:
# Log incoming request
logger.debug(f"🔵 {request.method} {request.url}")
logger.debug(f"Headers: {dict(request.headers)}")
if request.query_params:
logger.debug(f"Query params: {dict(request.query_params)}")
# Process the request
try:
response = await call_next(request)
if current_level == logging.DEBUG:
logger.debug(f"🟢 {request.method} {request.url}{response.status_code}")
return response
except Exception as exc:
if current_level == logging.DEBUG:
logger.debug(f"🔴 {request.method} {request.url} → Exception: {type(exc).__name__}")
raise
# Setup static files and templates
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
@ -67,17 +169,32 @@ async def create_recipe(
):
"""Create a new recipe"""
try:
logger.info(f"Creating recipe: name={name}, grams_out={grams_out}, timeout_seconds={timeout_seconds}")
recipe_id = db.execute_insert(
"INSERT INTO recipes (name, grams_out, timeout_seconds) VALUES (?, ?, ?)",
(name, grams_out, timeout_seconds)
)
logger.info(f"Recipe created with ID: {recipe_id}")
# Fetch updated recipes list
recipes = db.execute_query("SELECT * FROM recipes ORDER BY name")
logger.info(f"Fetched {len(recipes)} recipes for display")
if logger.getEffectiveLevel() == logging.DEBUG:
for recipe in recipes:
logger.debug(f"Recipe: {recipe}")
return templates.TemplateResponse("partials/recipe_list.html", {
"request": request,
"recipes": recipes
})
except Exception as e:
logger.error(f"Error creating recipe: {e}")
if logger.getEffectiveLevel() == logging.DEBUG:
logger.error(f"Recipe creation failed - Name: {name}, Grams: {grams_out}, Timeout: {timeout_seconds}")
logger.error(f"Full traceback: {traceback.format_exc()}")
raise HTTPException(status_code=400, detail="Failed to create recipe")
@app.delete("/recipes/{recipe_id}")
@ -112,14 +229,17 @@ async def create_button(
request: Request,
name: str = Form(...),
gpio_pin: int = Form(...),
recipe_id: Optional[int] = Form(None),
recipe_id: str = Form(""),
db: DatabaseManager = Depends(get_db)
):
"""Create a new button"""
try:
# Convert empty string to None, otherwise convert to int
recipe_id_value = None if recipe_id == "" else int(recipe_id)
button_id = db.execute_insert(
"INSERT INTO buttons (name, gpio_pin, recipe_id) VALUES (?, ?, ?)",
(name, gpio_pin, recipe_id if recipe_id else None)
(name, gpio_pin, recipe_id_value)
)
buttons = db.execute_query("""
SELECT b.*, r.name as recipe_name
@ -135,19 +255,25 @@ async def create_button(
})
except Exception as e:
logger.error(f"Error creating button: {e}")
if logger.getEffectiveLevel() == logging.DEBUG:
logger.error(f"Button creation failed - Name: {name}, GPIO: {gpio_pin}, Recipe ID: {recipe_id}")
logger.error(f"Full traceback: {traceback.format_exc()}")
raise HTTPException(status_code=400, detail="Failed to create button")
@app.put("/buttons/{button_id}")
async def update_button(
button_id: int,
recipe_id: Optional[int] = Form(None),
recipe_id: str = Form(""),
db: DatabaseManager = Depends(get_db)
):
"""Update button recipe mapping"""
try:
# Convert empty string to None, otherwise convert to int
recipe_id_value = None if recipe_id == "" else int(recipe_id)
db.execute_update(
"UPDATE buttons SET recipe_id = ? WHERE id = ?",
(recipe_id if recipe_id else None, button_id)
(recipe_id_value, button_id)
)
return {"status": "success"}
except Exception as e:

View File

@ -3,19 +3,35 @@
document.addEventListener('DOMContentLoaded', function() {
// Auto-hide Bootstrap modals after successful HTMX requests
document.body.addEventListener('htmx:afterRequest', function(event) {
console.log('HTMX Request completed:', event.detail);
if (event.detail.successful) {
console.log('HTMX Request was successful');
// Find any open modals and hide them
const openModals = document.querySelectorAll('.modal.show');
console.log('Found open modals:', openModals.length);
openModals.forEach(modal => {
console.log('Closing modal:', modal.id);
const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) {
modalInstance.hide();
} else {
// Fallback: hide manually
modal.style.display = 'none';
modal.classList.remove('show');
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop) {
backdrop.remove();
}
}
});
// Show success notification
showNotification('Operation completed successfully', 'success');
} else {
console.log('HTMX Request failed:', event.detail);
// Show error notification
showNotification('Operation failed. Please try again.', 'error');
}

View File

@ -30,7 +30,7 @@
<h5 class="modal-title">Add New Button</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form hx-post="/buttons" hx-target="#button-list" hx-on="htmx:afterRequest: htmx.closest(this, '.modal').style.display='none'">
<form hx-post="/buttons" hx-target="#button-list" hx-swap="innerHTML">
<div class="modal-body">
<div class="mb-3">
<label for="name" class="form-label">Button Name</label>

49
templates/error.html Normal file
View File

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Error {{ status_code }} - ECM Control{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="fas fa-exclamation-triangle"></i>
Error {{ status_code or 500 }}
</h4>
</div>
<div class="card-body">
<h5 class="card-title">{{ error_message or "An unexpected error occurred" }}</h5>
{% if error_detail %}
<div class="alert alert-warning mt-3">
<h6>Debug Information:</h6>
<pre class="mb-0"><code>{{ error_detail }}</code></pre>
</div>
{% endif %}
<div class="mt-4">
<a href="/" class="btn btn-primary">
<i class="fas fa-home"></i>
Return to Dashboard
</a>
<button onclick="history.back()" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
Go Back
</button>
</div>
</div>
</div>
<div class="mt-4">
<h6>Troubleshooting Tips:</h6>
<ul class="list-unstyled">
<li>• Check the server logs for more details</li>
<li>• Verify that all required fields are filled correctly</li>
<li>• Ensure your browser is up to date</li>
<li>• Try refreshing the page</li>
</ul>
</div>
</div>
</div>
{% endblock %}

View File

@ -30,7 +30,7 @@
<h5 class="modal-title">Add New Recipe</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form hx-post="/recipes" hx-target="#recipe-list" hx-on="htmx:afterRequest: htmx.closest(this, '.modal').style.display='none'">
<form hx-post="/recipes" hx-target="#recipe-list" hx-swap="innerHTML">
<div class="modal-body">
<div class="mb-3">
<label for="name" class="form-label">Recipe Name</label>

2
uv.lock generated
View File

@ -62,7 +62,7 @@ wheels = [
[[package]]
name = "ecm-control"
version = "0.1.0"
source = { virtual = "." }
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "gpiozero" },