diff --git a/Web UI Project/.github/copilot-instructions.md b/Web UI Project/.github/copilot-instructions.md new file mode 100644 index 0000000..b9db66c --- /dev/null +++ b/Web UI Project/.github/copilot-instructions.md @@ -0,0 +1,82 @@ +# AI Coding Agent Instructions + +## Project Overview +This is a 3D printer management web application that provides a real-time interface for monitoring and controlling a Bambu Labs printer via their API. The project uses FastAPI for the backend, HTMX for dynamic UI updates, and Jinja2 for templating. + +## Architecture + +### Key Components +- **FastAPI Backend** (`app/main.py`): Handles HTTP endpoints and printer communication +- **Templates** (`app/templates/`): Jinja2 templates with HTMX for dynamic updates +- **Static Assets** (`app/static/`): CSS and other static files +- **Docker Configuration**: Containerized deployment with live code reloading + +### Data Flow +1. FastAPI routes handle HTTP requests +2. Printer state is managed through `bambulabs_api` library +3. UI updates via HTMX endpoints returning partial HTML + +## Development Environment + +### Setup +```bash +# Build and start the container +docker compose up --build + +# Development server runs at: +# - HTTP: http://localhost:8000 +# - MQTT: ports 8883 (TLS), 6000 (unencrypted), 990 (WebSocket TLS) +``` + +### Live Development +- Code changes in `app/` are automatically reloaded +- Container mounts local `app/` directory for immediate updates + +## Key Patterns + +### Template Structure +- Base template: `templates/base.html` +- Partial templates prefixed with underscore (e.g., `_status.html`, `_connect_area.html`) +- HTMX used for dynamic content updates + +### State Management +- Connection state tracked in-memory via global variable +- Printer API interactions wrapped in try/except blocks +- Status updates polled every 500ms via HTMX trigger + +### UI Components +- Status panel auto-refreshes using `hx-trigger="every 500ms"` +- Connection state toggles between connect/disconnect UI +- Error handling displayed in status panel + +## Integration Points +- **Bambu Labs API**: Primary integration via `bambulabs_api` package +- **MQTT Communication**: Used for real-time printer updates +- **Configuration File**: Printer connection details stored in `app/config.json`: + ```json + { + "printer": { + "ip": "10.0.2.10", + "access_code": "e0f815a7", + "serial": "00M09A360300272" + } + } + ``` + +## Common Tasks + +### Adding New Printer Data +1. Add new field to `/status` endpoint in `main.py` +2. Update status dictionary in the route handler +3. Add corresponding UI element in `_status.html` template + +### Error Handling +- Wrap printer API calls in try/except blocks +- Return error status through template context +- Display errors in UI using dedicated error states + +## File Reference +- `main.py`: Core application logic and routes +- `templates/_status.html`: Real-time printer status display +- `templates/index.html`: Main page layout and connection UI +- `docker-compose.yaml`: Container configuration and port mappings \ No newline at end of file diff --git a/Web UI Project/app/__pycache__/__init__.cpython-312.pyc b/Web UI Project/app/__pycache__/__init__.cpython-312.pyc index 53d8f04..9e1b6dc 100644 Binary files a/Web UI Project/app/__pycache__/__init__.cpython-312.pyc and b/Web UI Project/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/Web UI Project/app/__pycache__/main.cpython-312.pyc b/Web UI Project/app/__pycache__/main.cpython-312.pyc index 3d5c8ee..ecbb8ca 100644 Binary files a/Web UI Project/app/__pycache__/main.cpython-312.pyc and b/Web UI Project/app/__pycache__/main.cpython-312.pyc differ diff --git a/Web UI Project/app/config.json b/Web UI Project/app/config.json new file mode 100644 index 0000000..dc15f0b --- /dev/null +++ b/Web UI Project/app/config.json @@ -0,0 +1,61 @@ +{ + "printers": [ + { + "id": "x1c", + "name": "X1C", + "ip": "10.0.2.10", + "access_code": "e0f815a7", + "serial": "00M09A360300272" + }, + { + "id": "x1c2", + "name": "X1C #2", + "ip": "10.0.2.10", + "access_code": "e0f815a7", + "serial": "00M09A360300272" + }, + { + "id": "x1c3", + "name": "X1C #3", + "ip": "10.0.2.10", + "access_code": "e0f815a7", + "serial": "00M09A360300272" + }, + { + "id": "x1c4", + "name": "X1C #4", + "ip": "10.0.2.10", + "access_code": "e0f815a7", + "serial": "00M09A360300272" + }, + { + "id": "x1c5", + "name": "X1C #5", + "ip": "10.0.2.10", + "access_code": "e0f815a7", + "serial": "00M09A360300272" + }, + { + "id": "x1c6", + "name": "X1C #6", + "ip": "10.0.2.10", + "access_code": "e0f815a7", + "serial": "00M09A360300272" + }, + { + "id": "x1c7", + "name": "X1C #7", + "ip": "10.0.2.10", + "access_code": "e0f815a7", + "serial": "00M09A360300272" + }, + { + "id": "x1c8", + "name": "X1C #8", + "ip": "10.0.2.10", + "access_code": "e0f815a7", + "serial": "00M09A360300272" + } + + ] +} \ No newline at end of file diff --git a/Web UI Project/app/main.py b/Web UI Project/app/main.py index 7b82b17..d6c7444 100644 --- a/Web UI Project/app/main.py +++ b/Web UI Project/app/main.py @@ -1,19 +1,60 @@ -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, HTTPException from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import uvicorn import bambulabs_api as bl import os +import json from datetime import datetime, timedelta +from typing import Dict, Optional +from zoneinfo import ZoneInfo +import time +import logging -app = FastAPI(title="X1C Connection") +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) -IP = "10.0.2.10" -ACCESS_CODE = "e0f815a7" -SERIAL = "00M09A360300272" +app = FastAPI(title="Printer Manager") -printer = bl.Printer(IP, ACCESS_CODE, SERIAL) +class PrinterInstance: + def __init__(self, config: dict): + self.id = config['id'] + self.name = config['name'] + self.connected = False + self.printer = bl.Printer( + config['ip'], + config['access_code'], + config['serial'] + ) + self.status = {} + self.target_temps = { + 'bed': 0, + 'nozzle': 0 + } + self.last_status_update = 0 # Track when we last updated status + +# Dictionary to store printer instances +printers: Dict[str, PrinterInstance] = {} + +# Load configuration from JSON file +try: + config_path = os.path.join(os.path.dirname(__file__), 'config.json') + logger.info(f"Loading config from: {config_path}") + with open(config_path, 'r') as f: + config = json.load(f) + logger.info(f"Found {len(config['printers'])} printers in config") + for printer_config in config['printers']: + logger.info(f"Loading printer: {printer_config['name']} (ID: {printer_config['id']})") + printers[printer_config['id']] = PrinterInstance(printer_config) + logger.info(f"Successfully loaded {len(printers)} printers") +except (FileNotFoundError, json.JSONDecodeError, KeyError) as e: + logger.error(f"Error loading configuration: {str(e)}") + printers = {} # Serve /static/* app.mount("/static", StaticFiles(directory="app/static"), name="static") @@ -26,76 +67,301 @@ connectionState = False @app.get("/", response_class=HTMLResponse) def home(request: Request): - # Render page and pass current connection state - try: - connected = bool(connectionState) - except NameError: - connected = False - return templates.TemplateResponse("index.html", {"request": request, "connected": connected}) - - -@app.post("/connect", response_class=HTMLResponse) -def connect(request: Request): - global connectionState - try: - printer.mqtt_start() - if(printer.mqtt_start()): - connectionState = True - except Exception: - connectionState = False - - # Return the connect area so HTMX can swap the button immediately - return templates.TemplateResponse("_connect_area.html", {"request": request, "connected": connectionState}) - - -@app.post("/disconnect", response_class=HTMLResponse) -def disconnect(request: Request): - global connectionState - try: - printer.mqtt_stop() - connectionState = False - except Exception: - connectionState = False - - # Return the connect area so HTMX can swap the button immediately - return templates.TemplateResponse("_connect_area.html", {"request": request, "connected": connectionState}) - - -@app.post("/status", response_class=HTMLResponse) -def status(request: Request): - - - try: - # Preferred direct calls (if the API exposes them) - status = {} - status['bed_temp'] = printer.get_bed_temperature() - status['nozzle_temp'] = printer.get_nozzle_temperature() - status['chamber_temp'] = printer.get_chamber_temperature() - status['percentage'] = printer.get_percentage() - remaining_time = printer.get_time() - current = printer.get_file_name() - except Exception as e: - return templates.TemplateResponse("_status.html", {"request": request, "status": {"error": str(e)}}) - - # Normalize current file: remove /data/Metadata/ prefix or use basename - current_file = str(current or '') - if current_file.startswith('/data/Metadata/'): - current_file = current_file[len('/data/Metadata/'):] - else: - current_file = os.path.basename(current_file) - status['current_file'] = current_file or 'N/A' - - if remaining_time is not None: - finish_time = datetime.now() + timedelta(seconds=int(remaining_time)) - status['finish_time'] = finish_time.strftime("%Y-%m-%d %H:%M:%S") - status['remaining_time'] = timedelta(seconds=int(remaining_time)) - - else: - status['finish_time'] = "NA" - status['remaining_time'] = "N/A" + return templates.TemplateResponse("index.html", { + "request": request, + "printers": printers + }) + +@app.post("/connect-all", response_class=HTMLResponse) +async def connect_all(request: Request): + # Try to connect all printers + for printer_instance in printers.values(): + try: + if not printer_instance.connected: + success = printer_instance.printer.mqtt_start() + if success: + printer_instance.connected = True + except Exception: + continue # Skip failed connections and continue with others + + # Render the updated container contents + html_content = templates.get_template("_printer_grid.html").render( + request=request, + printers=printers + ) + return HTMLResponse(content=html_content) +@app.post("/connect/{printer_id}", response_class=HTMLResponse) +async def connect(request: Request, printer_id: str): + if printer_id not in printers: + return templates.TemplateResponse( + "_printer_card.html", + { + "request": request, + "printer": None, + "error": "Printer not found" + } + ) + printer_instance = printers[printer_id] + try: + logger.info(f"Attempting to connect printer {printer_id}") + + # First attempt + logger.debug(f"Making first connection attempt for {printer_id}") + first_attempt = printer_instance.printer.mqtt_start() + logger.debug(f"First connection attempt result: {first_attempt}") + + # Delay between attempts + time.sleep(1.0) + + # Second attempt (known workaround for API behavior) + logger.debug(f"Making second connection attempt for {printer_id}") + success = printer_instance.printer.mqtt_start() + logger.debug(f"Second connection attempt result: {success}") + + if not success: + raise Exception("Failed to establish MQTT connection") + + printer_instance.connected = True + logger.info(f"Successfully connected printer {printer_id}") + except Exception as e: + printer_instance.connected = False + return templates.TemplateResponse( + "_printer_card.html", + { + "request": request, + "printer": printer_instance, + "error": f"Failed to connect: {str(e)}" + } + ) + + # Return the entire printer card to update the UI + return templates.TemplateResponse("_printer_card.html", { + "request": request, + "printer": printer_instance + }) + + +@app.post("/disconnect/{printer_id}", response_class=HTMLResponse) +async def disconnect(request: Request, printer_id: str): + if printer_id not in printers: + return templates.TemplateResponse( + "_printer_card.html", + { + "request": request, + "printer": None, + "error": "Printer not found" + } + ) + + printer_instance = printers[printer_id] + try: + logger.info(f"Stopping MQTT for printer {printer_id}") + printer_instance.printer.mqtt_stop() + time.sleep(0.5) # Give it time to fully disconnect + printer_instance.connected = False + logger.info(f"Successfully disconnected printer {printer_id}") + except Exception as e: + logger.error(f"Error during disconnect: {e}") + printer_instance.connected = False + + # Return the entire printer card to update the UI + return templates.TemplateResponse("_printer_card.html", { + "request": request, + "printer": printer_instance + }) + + +@app.post("/set-temperature/{printer_id}", response_class=HTMLResponse) +async def set_temperature(request: Request, printer_id: str): + if printer_id not in printers: + print(f"Printer {printer_id} not found") + return HTMLResponse(content="Printer not found", status_code=404) + + printer_instance = printers[printer_id] + form_data = await request.form() + temp_type = form_data.get("type") + temp_value = form_data.get("value") + print(f"Setting {temp_type} temperature to {temp_value} for printer {printer_id}") + + try: + temp_value = float(temp_value) + success = False + + if temp_type == "nozzle": + success = printer_instance.printer.set_nozzle_temperature(temp_value) + if success: + printer_instance.target_temps['nozzle'] = int(temp_value) + logger.info(f"Set nozzle temp to {temp_value}: {'success' if success else 'failed'}") + elif temp_type == "bed": + success = printer_instance.printer.set_bed_temperature(temp_value) + if success: + printer_instance.target_temps['bed'] = int(temp_value) + logger.info(f"Set bed temp to {temp_value}: {'success' if success else 'failed'}") + + if not success: + error_msg = f"Failed to set {temp_type} temperature to {temp_value}" + logger.error(error_msg) + return HTMLResponse( + content=error_msg, + status_code=400, + headers={"HX-Trigger": f"showMessage:Failed to set temperature"} + ) + + return HTMLResponse(content=str(temp_value)) + except Exception as e: + print(f"Error setting temperature: {str(e)}") + return HTMLResponse( + content=f"Error: {str(e)}", + status_code=400, + headers={"HX-Trigger": f"showMessage:{str(e)}"} + ) + +@app.post("/status/{printer_id}", response_class=HTMLResponse) +async def status(request: Request, printer_id: str): + if printer_id not in printers: + return templates.TemplateResponse( + "_status.html", + { + "request": request, + "printer": None, + "status": {"error": "Printer not found"} + } + ) + + printer_instance = printers[printer_id] + if not printer_instance.connected: + # Try to reconnect if disconnected + try: + success = printer_instance.printer.mqtt_start() + if success: + printer_instance.connected = True + else: + return templates.TemplateResponse( + "_status.html", + { + "request": request, + "printer": printer_instance, + "status": {"error": "Printer disconnected. Try reconnecting."} + } + ) + except Exception: + return templates.TemplateResponse( + "_status.html", + { + "request": request, + "printer": printer_instance, + "status": {"error": "Printer disconnected. Try reconnecting."} + } + ) + + try: + current_time = time.time() + # If it's been less than 0.8 seconds since last update, return cached status + if current_time - printer_instance.last_status_update < 0.8: + return templates.TemplateResponse("_status.html", { + "request": request, + "printer": printer_instance, + "status": printer_instance.status + }) + + printer_instance.last_status_update = current_time + + status = {} + # Add debug info + status['debug_info'] = { + 'id': printer_instance.id, + 'name': printer_instance.name, + 'connected': printer_instance.connected + } + + # Get printer status and format temperatures as integers + print(f"Getting status for printer {printer_instance.id}") + + try: + bed_temp = printer_instance.printer.get_bed_temperature() + logger.debug(f"Bed temp raw: {bed_temp}") + status['bed_temp'] = int(float(bed_temp)) if bed_temp is not None else 0 + except Exception as e: + logger.warning(f"Error getting bed temp: {e}") + status['bed_temp'] = 0 + + status['bed_target'] = printer_instance.target_temps['bed'] + + try: + nozzle_temp = printer_instance.printer.get_nozzle_temperature() + logger.debug(f"Nozzle temp raw: {nozzle_temp}") + status['nozzle_temp'] = int(float(nozzle_temp)) if nozzle_temp is not None else 0 + except Exception as e: + logger.warning(f"Error getting nozzle temp: {e}") + status['nozzle_temp'] = 0 + + status['nozzle_target'] = printer_instance.target_temps['nozzle'] + + try: + chamber_temp = printer_instance.printer.get_chamber_temperature() + logger.debug(f"Chamber temp raw: {chamber_temp}") + status['chamber_temp'] = int(float(chamber_temp)) if chamber_temp is not None else 0 + except Exception as e: + logger.warning(f"Error getting chamber temp: {e}") + status['chamber_temp'] = 0 + + try: + percentage = printer_instance.printer.get_percentage() + logger.debug(f"Progress percentage: {percentage}") + status['percentage'] = percentage if percentage is not None else "0%" + except Exception as e: + logger.warning(f"Error getting percentage: {e}") + status['percentage'] = "0%" + + try: + remaining_time = printer_instance.printer.get_time() + logger.debug(f"Remaining time: {remaining_time}") + except Exception as e: + logger.warning(f"Error getting time: {e}") + remaining_time = None + + try: + current = printer_instance.printer.get_file_name() + logger.debug(f"Current file: {current}") + except Exception as e: + logger.warning(f"Error getting file name: {e}") + current = None + except Exception as e: + printer_instance.connected = False # Mark as disconnected on error + return templates.TemplateResponse( + "_status.html", + { + "request": request, + "printer": printer_instance, + "status": {"error": f"Error getting status: {str(e)}"} + } + ) + + # Set current file and time info based on remaining time + if remaining_time is None or int(remaining_time) == 0: + status['current_file'] = 'N/A' + status['finish_time'] = 'N/A' + status['remaining_time'] = 'N/A' + else: + # Normalize current file: remove /data/Metadata/ prefix or use basename + current_file = str(current or '') + if current_file.startswith('/data/Metadata/'): + current_file = current_file[len('/data/Metadata/'):] + else: + current_file = os.path.basename(current_file) + status['current_file'] = current_file or 'N/A' + + # Set time information with proper timezone handling + local_tz = ZoneInfo('localtime') + now = datetime.now(local_tz) + finish_time = now + timedelta(seconds=int(remaining_time)) + status['finish_time'] = finish_time.strftime("%I:%M %p") # Show time in 12-hour format with AM/PM + if finish_time.date() > now.date(): + status['finish_time'] = finish_time.strftime("%Y-%m-%d %I:%M %p") # Include date if not today + status['remaining_time'] = str(timedelta(seconds=int(remaining_time))) # stringify values for k in status: @@ -104,7 +370,12 @@ def status(request: Request): except Exception: status[k] = 'N/A' - return templates.TemplateResponse("_status.html", {"request": request, "status": status}) + printer_instance.status = status + return templates.TemplateResponse("_status.html", { + "request": request, + "printer": printer_instance, + "status": status + }) diff --git a/Web UI Project/app/templates/_connect_area.html b/Web UI Project/app/templates/_connect_area.html deleted file mode 100644 index 698db70..0000000 --- a/Web UI Project/app/templates/_connect_area.html +++ /dev/null @@ -1,26 +0,0 @@ -
- {% if connected %} -
- -
- - -
- {% else %} -
- -
- {% endif %} -
diff --git a/Web UI Project/app/templates/_printer_card.html b/Web UI Project/app/templates/_printer_card.html new file mode 100644 index 0000000..e9ba8aa --- /dev/null +++ b/Web UI Project/app/templates/_printer_card.html @@ -0,0 +1,34 @@ +
+

{{ printer.name }}

+ + + {% if printer.connected %} +
+ +
+ + +
+
+ {% else %} +
+ +
+ {% endif %} +
\ No newline at end of file diff --git a/Web UI Project/app/templates/_printer_grid.html b/Web UI Project/app/templates/_printer_grid.html new file mode 100644 index 0000000..3f96c92 --- /dev/null +++ b/Web UI Project/app/templates/_printer_grid.html @@ -0,0 +1,14 @@ +
+ {% for printer in printers.values() %} + {% include "_printer_card.html" %} + {% else %} +
+ No printers configured. Check config.json file. +
+ {% endfor %} +
+ + +
+ Total Printers: {{ printers|length }} +
\ No newline at end of file diff --git a/Web UI Project/app/templates/_status.html b/Web UI Project/app/templates/_status.html index 80caa80..34742d7 100644 --- a/Web UI Project/app/templates/_status.html +++ b/Web UI Project/app/templates/_status.html @@ -1,37 +1,104 @@ -
+
{% if status.error %}
Error: {{ status.error }}
+ {% if status.debug_info %} +
+
Printer ID: {{ status.debug_info.id }}
+
Name: {{ status.debug_info.name }}
+
Connected: {{ status.debug_info.connected }}
+
+ {% endif %} {% else %} -
-
-
Current File
-
{{ status.current_file }}
-
-
-
Nozzle Temperature
-
{{ status.nozzle_temp }}
-
-
-
Bed Temperature
-
{{ status.bed_temp }}
+
+ +
+ Current File + {{ status.current_file }}
-
-
Chamber Temperature
-
{{ status.chamber_temp }}
+ + + +
+ Nozzle Temp +
+ {{ status.nozzle_temp|int }} + / +
+ + +
+
-
-
Progress
-
{{ status.percentage }}
+ +
+ Bed Temp +
+ {{ status.bed_temp|int }} + / +
+ + +
+
-
-
Remaining Time
-
{{ status.remaining_time }}
+ +
+ Chamber Temp + {{ status.chamber_temp }}
-
-
Time Done
-
{{ status.finish_time }}
+ +
+ Progress + {{ status.percentage }}
+ +
+ Time Left + {{ status.remaining_time }} +
+ +
+ Est. Done + {{ status.finish_time }} +
+
{% endif %}
diff --git a/Web UI Project/app/templates/base.html b/Web UI Project/app/templates/base.html index 291edd3..64129b5 100644 --- a/Web UI Project/app/templates/base.html +++ b/Web UI Project/app/templates/base.html @@ -10,9 +10,21 @@ + + -
+

X1 Carbon Connection

{% block connect %}{% endblock %}
diff --git a/Web UI Project/app/templates/index.html b/Web UI Project/app/templates/index.html index 2eef3fc..abeb066 100644 --- a/Web UI Project/app/templates/index.html +++ b/Web UI Project/app/templates/index.html @@ -1,52 +1,47 @@ {% extends "base.html" %} {% block connect %} - -
- {% if connected %} +
+ +
-
+
- -
- {% else %} -
- -
- {% endif %} + +
+
+ {% for printer in printers.values() %} +
+ {% include "_printer_card.html" %} +
+ {% else %} +
+ No printers configured. Check config.json file. +
+ {% endfor %} +
+ + +
+ Total Printers: {{ printers|length }} +
+
- -{% endblock %} - -{% block status %} - - - - - + {% endblock %} diff --git a/Web UI Project/docker-compose.yaml b/Web UI Project/docker-compose.yaml index e6a5a1b..aa99db4 100644 --- a/Web UI Project/docker-compose.yaml +++ b/Web UI Project/docker-compose.yaml @@ -1,8 +1,8 @@ services: web: build: . - image: fastapi-htmx:dev - container_name: fastapi-htmx + image: print-manager:dev + container_name: print-manager ports: - "8000:8000" - "8883:8883" # MQTT over TLS