diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ffde3f0 --- /dev/null +++ b/CLAUDE.md @@ -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 " +``` + +**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. \ No newline at end of file diff --git a/README.md b/README.md index d211078..8b39b31 100644 Binary files a/README.md and b/README.md differ diff --git a/ecm_control.db b/ecm_control.db new file mode 100644 index 0000000..0529fd0 Binary files /dev/null and b/ecm_control.db differ diff --git a/logs/performance.log b/logs/performance.log new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 4b84e6e..77072ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/run.py b/run.py new file mode 100755 index 0000000..d7d9b57 --- /dev/null +++ b/run.py @@ -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()) \ No newline at end of file diff --git a/src/ecm_control/__main__.py b/src/ecm_control/__main__.py index 53e016e..c13d1fe 100644 --- a/src/ecm_control/__main__.py +++ b/src/ecm_control/__main__.py @@ -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 diff --git a/src/ecm_control/web/__init__.py b/src/ecm_control/web/__init__.py index a96623d..fbee640 100644 --- a/src/ecm_control/web/__init__.py +++ b/src/ecm_control/web/__init__.py @@ -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""" + + Error + +

Internal Server Error

+

An unexpected error occurred. Please try again.

+ {f'
{str(exc)}
' if current_level == logging.DEBUG else ''} + + + """, + 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: diff --git a/static/js/app.js b/static/js/app.js index 911e982..884e886 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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'); } diff --git a/templates/buttons.html b/templates/buttons.html index e0c097e..a067bb6 100644 --- a/templates/buttons.html +++ b/templates/buttons.html @@ -30,7 +30,7 @@ -
+