From 087abca062601f4d43ad09cd2acfa3b6dd1dd05d Mon Sep 17 00:00:00 2001 From: Marc Boivin Date: Fri, 20 Jun 2025 06:27:47 -0400 Subject: [PATCH] fix: Resolve recipe/button form submission and add comprehensive debug logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 188 ++++++++++++++++++++++++++++++++ README.md | Bin 7432 -> 7400 bytes ecm_control.db | Bin 0 -> 57344 bytes logs/performance.log | 0 pyproject.toml | 13 ++- run.py | 16 +++ src/ecm_control/__main__.py | 24 ++-- src/ecm_control/web/__init__.py | 136 ++++++++++++++++++++++- static/js/app.js | 16 +++ templates/buttons.html | 2 +- templates/error.html | 49 +++++++++ templates/recipes.html | 2 +- uv.lock | 2 +- 13 files changed, 431 insertions(+), 17 deletions(-) create mode 100644 CLAUDE.md create mode 100644 ecm_control.db create mode 100644 logs/performance.log create mode 100755 run.py create mode 100644 templates/error.html 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 d211078ce2c5dc5eca270d2a8e5b6f3a909e3cc1..8b39b310ed7585bbf2fa2ee4dff2f53d9372044e 100644 GIT binary patch delta 186 zcmeCMdSSU?F)Le9X`Ws|<>Za5QBdY)Nj6m`n6M9r9gI1HBOJ#3%i#)B9Kbn+S@-?^ zy$V68If<1D!I|lKi8)-l3Z`KS2+=TSXP}U~p9@HAGB>XjGl*8>Js=NK0~80!I+d1` i@x$Doh~8WK#}XFkQ(3k`S7Flfw~6OKjHS>|$1* zq!5&vlUS(`oSB}Nn8T&3U>c?X6AN>81`4?QxhPDU{E$bAanj^}JO_Z}L0(B9d6!oj ONd5+~H;eOKEm#00(e8&PS6id7vR>qbhsS*qUhVmNeVCmcz(WcSLK`ktW(E1eTk;rPkcI ziyhb)n#33fkak4kg0$z{}fB*!ZQ-QbR;pEX# z@ru^0v&&ljva-^)ETd^EjdjgX*0rX$o+y;%d_|_k(wuyb4ogUvO4Li7>SRuz+Zj&g zl484Cyt!&vrqW_HZJjAv-C2!qD0?84U9=OF>Sj!z+cC6cw+5Y(cWzR0H&w6XZczHI z7$-^kNZi_(mfEruOIu^k`i@PC?j`G$-3v=!e`zF=92*lqx@xH_Iy0GNY0ZY&`Hp&} zR`MqoWZDU(sTk!itVf?EBu#QrMM_BSMrNz z=$t&CuP#(*p;|8U%_`j(H&1)LzJ)xo^xtUGsDGx!!K6b{h|-c8lVb zx7ucZ_n?J0?_4O<-aLawGsjdwidws`d=ghgG1^ZHU_ob8U;bFISq`KSLvg>3x zoXn4Ic6fV~%9OVdnG8Kh@~oFUd`1c<4;~cHyJrIT+-HzJ5S-`oWETPMKaO@xB6mWpq2x7EjUt*AMIyfWnWWVJ89a02@4h2Tm0abC_+d zH^kDDf%~-3ArKLc+QlrYts0$IHH-3*Us`FmRt)o1(qBksX7a!0uX=z1Rwwb2tWV=5P$##An;rX#6@W=(77o~xVuA=Mn!M3e{e^DuXy)p zgt$sV>?HgkPAnmm&;{~!LMMk3zb3wXuGFxz5P$##AOHafKmY;|fB*y_@B{>8KHGh8 z()^s!=6}9I?Md^Y;K2O!;~OT;XPlr=MA&7|cV95{sMMb)du;Ydc1V!S=lVOF_O@rw z*Y|BY66nvvw{zs!kf1qd+J7{c^A+Qp_8;DmWol+f5T~7=5rw{+2Yi(YZOAfs?|{QF z$EW=dmSVbhBiMhxGKe`nyP+<2kUjrDL~aV?Gjf;QB;S!=o^00Izz00bZa0SG_<0uX?}Gc4df|1U3)TVyP8JCT2ecNMz^ z0SG_<0uX=z1Rwwb2tWV=k0Nk^-&wymS zXt6ZEG(pei%cWxJ)C85w<)w1Ed%OE_I+dH9IzF2jy!AfAAAHy1x86^ai@Dr;Olz!K zifO4Uny$Ui6iZuUxeEWFmOj|C@o zUCrg}JW9=2Ti2PT8BN7nZ809E*E5H*XPpYs+N#=Y@H%v2XsU78OxqQx8BJ5Gdt2sf zPATlcs_+iK%YQsx(ysnm&~C-pY748vNbUYZ9h-+lhy zZGqh8v;V&*Up-1!Kwbzy00Izz00bZa0SG_<0uX=z1RhZ!F6|Y&LoC5P(zG~qAVeCI zPKcY1Rs>#<#-n{B3HJ3r=Knup+aNmxAOHafKmY;|fB*y_009U=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 @@ -
+