More work on the management UI, multi printer is working, temps is sometimes working
This commit is contained in:
82
Web UI Project/.github/copilot-instructions.md
vendored
Normal file
82
Web UI Project/.github/copilot-instructions.md
vendored
Normal file
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
61
Web UI Project/app/config.json
Normal file
61
Web UI Project/app/config.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,19 +1,60 @@
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import bambulabs_api as bl
|
import bambulabs_api as bl
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta
|
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"
|
app = FastAPI(title="Printer Manager")
|
||||||
ACCESS_CODE = "e0f815a7"
|
|
||||||
SERIAL = "00M09A360300272"
|
|
||||||
|
|
||||||
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/*
|
# Serve /static/*
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
@@ -26,57 +67,285 @@ connectionState = False
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def home(request: Request):
|
def home(request: Request):
|
||||||
# Render page and pass current connection state
|
return templates.TemplateResponse("index.html", {
|
||||||
try:
|
"request": request,
|
||||||
connected = bool(connectionState)
|
"printers": printers
|
||||||
except NameError:
|
})
|
||||||
connected = False
|
|
||||||
return templates.TemplateResponse("index.html", {"request": request, "connected": connected})
|
|
||||||
|
|
||||||
|
@app.post("/connect-all", response_class=HTMLResponse)
|
||||||
@app.post("/connect", response_class=HTMLResponse)
|
async def connect_all(request: Request):
|
||||||
def connect(request: Request):
|
# Try to connect all printers
|
||||||
global connectionState
|
for printer_instance in printers.values():
|
||||||
try:
|
try:
|
||||||
printer.mqtt_start()
|
if not printer_instance.connected:
|
||||||
if(printer.mqtt_start()):
|
success = printer_instance.printer.mqtt_start()
|
||||||
connectionState = True
|
if success:
|
||||||
|
printer_instance.connected = True
|
||||||
except Exception:
|
except Exception:
|
||||||
connectionState = False
|
continue # Skip failed connections and continue with others
|
||||||
|
|
||||||
# Return the connect area so HTMX can swap the button immediately
|
# Render the updated container contents
|
||||||
return templates.TemplateResponse("_connect_area.html", {"request": request, "connected": connectionState})
|
html_content = templates.get_template("_printer_grid.html").render(
|
||||||
|
request=request,
|
||||||
|
printers=printers
|
||||||
|
)
|
||||||
|
return HTMLResponse(content=html_content)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/disconnect", response_class=HTMLResponse)
|
@app.post("/connect/{printer_id}", response_class=HTMLResponse)
|
||||||
def disconnect(request: Request):
|
async def connect(request: Request, printer_id: str):
|
||||||
global connectionState
|
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:
|
try:
|
||||||
printer.mqtt_stop()
|
logger.info(f"Attempting to connect printer {printer_id}")
|
||||||
connectionState = False
|
|
||||||
except Exception:
|
|
||||||
connectionState = False
|
|
||||||
|
|
||||||
# Return the connect area so HTMX can swap the button immediately
|
# First attempt
|
||||||
return templates.TemplateResponse("_connect_area.html", {"request": request, "connected": connectionState})
|
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)
|
||||||
|
|
||||||
@app.post("/status", response_class=HTMLResponse)
|
# Second attempt (known workaround for API behavior)
|
||||||
def status(request: Request):
|
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")
|
||||||
|
|
||||||
try:
|
printer_instance.connected = True
|
||||||
# Preferred direct calls (if the API exposes them)
|
logger.info(f"Successfully connected printer {printer_id}")
|
||||||
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:
|
except Exception as e:
|
||||||
return templates.TemplateResponse("_status.html", {"request": request, "status": {"error": str(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
|
# Normalize current file: remove /data/Metadata/ prefix or use basename
|
||||||
current_file = str(current or '')
|
current_file = str(current or '')
|
||||||
if current_file.startswith('/data/Metadata/'):
|
if current_file.startswith('/data/Metadata/'):
|
||||||
@@ -85,17 +354,14 @@ def status(request: Request):
|
|||||||
current_file = os.path.basename(current_file)
|
current_file = os.path.basename(current_file)
|
||||||
status['current_file'] = current_file or 'N/A'
|
status['current_file'] = current_file or 'N/A'
|
||||||
|
|
||||||
if remaining_time is not None:
|
# Set time information with proper timezone handling
|
||||||
finish_time = datetime.now() + timedelta(seconds=int(remaining_time))
|
local_tz = ZoneInfo('localtime')
|
||||||
status['finish_time'] = finish_time.strftime("%Y-%m-%d %H:%M:%S")
|
now = datetime.now(local_tz)
|
||||||
status['remaining_time'] = timedelta(seconds=int(remaining_time))
|
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
|
||||||
else:
|
if finish_time.date() > now.date():
|
||||||
status['finish_time'] = "NA"
|
status['finish_time'] = finish_time.strftime("%Y-%m-%d %I:%M %p") # Include date if not today
|
||||||
status['remaining_time'] = "N/A"
|
status['remaining_time'] = str(timedelta(seconds=int(remaining_time)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# stringify values
|
# stringify values
|
||||||
for k in status:
|
for k in status:
|
||||||
@@ -104,7 +370,12 @@ def status(request: Request):
|
|||||||
except Exception:
|
except Exception:
|
||||||
status[k] = 'N/A'
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<div id="connect-Button">
|
|
||||||
{% if connected %}
|
|
||||||
<form
|
|
||||||
hx-post="/disconnect"
|
|
||||||
hx-target="#connect-Button"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
<button class="px-4 py-2 rounded bg-green-600 text-white hover:bg-red-600">
|
|
||||||
Printer is connected. (Press to disconnect)
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Live status panel, will load once when revealed and then the returned fragment polls itself -->
|
|
||||||
<div id="live-status" hx-post="/status" hx-trigger="revealed" hx-swap="outerHTML" class="mt-4"></div>
|
|
||||||
{% else %}
|
|
||||||
<form
|
|
||||||
hx-post="/connect"
|
|
||||||
hx-target="#connect-Button"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-500">
|
|
||||||
Connect to Printer
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
34
Web UI Project/app/templates/_printer_card.html
Normal file
34
Web UI Project/app/templates/_printer_card.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<div id="printer-{{ printer.id }}" class="p-6 rounded border border-slate-700 bg-slate-800 w-full h-fit">
|
||||||
|
<h2 class="text-xl font-bold mb-4 text-white" title="{{ printer.name }}">{{ printer.name }}</h2>
|
||||||
|
|
||||||
|
<!-- Connect/Disconnect button -->
|
||||||
|
{% if printer.connected %}
|
||||||
|
<form
|
||||||
|
hx-post="/disconnect/{{ printer.id }}"
|
||||||
|
hx-target="#printer-{{ printer.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<button class="w-full px-4 py-2 rounded bg-green-600 text-white hover:bg-red-600">
|
||||||
|
{{ printer.name }} is connected (Press to disconnect)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Live status panel -->
|
||||||
|
<div id="status-{{ printer.id }}"
|
||||||
|
hx-post="/status/{{ printer.id }}"
|
||||||
|
hx-trigger="every 500ms[!document.activeElement.matches('#status-{{ printer.id }} input')]"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="mt-4">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form
|
||||||
|
hx-post="/connect/{{ printer.id }}"
|
||||||
|
hx-target="#printer-{{ printer.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<button class="w-full px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-500">
|
||||||
|
Connect to {{ printer.name }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
14
Web UI Project/app/templates/_printer_grid.html
Normal file
14
Web UI Project/app/templates/_printer_grid.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 auto-rows-fr">
|
||||||
|
{% for printer in printers.values() %}
|
||||||
|
{% include "_printer_card.html" %}
|
||||||
|
{% else %}
|
||||||
|
<div class="col-span-full text-center text-slate-400">
|
||||||
|
No printers configured. Check config.json file.
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug info -->
|
||||||
|
<div class="mt-8 text-sm text-slate-500">
|
||||||
|
Total Printers: {{ printers|length }}
|
||||||
|
</div>
|
||||||
@@ -1,36 +1,103 @@
|
|||||||
<div id="status-panel" hx-post="/status" hx-trigger="every 500ms" hx-swap="outerHTML" class="rounded bg-slate-800 border border-slate-700 p-4 text-slate-100">
|
<div id="status-{{ printer.id }}"
|
||||||
|
hx-post="/status/{{ printer.id }}"
|
||||||
|
hx-trigger="every 1s"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#status-{{ printer.id }}"
|
||||||
|
hx-select="#status-{{ printer.id }}">
|
||||||
{% if status.error %}
|
{% if status.error %}
|
||||||
<div class="text-red-400">Error: {{ status.error }}</div>
|
<div class="text-red-400">Error: {{ status.error }}</div>
|
||||||
|
{% if status.debug_info %}
|
||||||
|
<div class="mt-2 text-sm text-slate-400">
|
||||||
|
<div>Printer ID: {{ status.debug_info.id }}</div>
|
||||||
|
<div>Name: {{ status.debug_info.name }}</div>
|
||||||
|
<div>Connected: {{ status.debug_info.connected }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="w-full space-y-2">
|
||||||
<div class="col-span-2">
|
<!-- Current File -->
|
||||||
<div class="text-sm text-slate-400">Current File</div>
|
<div class="flex justify-between items-center">
|
||||||
<div class="font-medium">{{ status.current_file }}</div>
|
<span class="text-sm text-slate-400">Current File</span>
|
||||||
</div>
|
<span class="font-medium">{{ status.current_file }}</span>
|
||||||
<div>
|
|
||||||
<div class="text-sm text-slate-400">Nozzle Temperature</div>
|
|
||||||
<div class="font-medium">{{ status.nozzle_temp }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-slate-400">Bed Temperature</div>
|
|
||||||
<div class="font-medium">{{ status.bed_temp }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Status Items -->
|
||||||
<div class="text-sm text-slate-400">Chamber Temperature</div>
|
<style>
|
||||||
<div class="font-medium">{{ status.chamber_temp }}</div>
|
/* Remove arrows/spinners from number inputs */
|
||||||
|
input[type=number]::-webkit-inner-spin-button,
|
||||||
|
input[type=number]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type=number] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-slate-400">Nozzle Temp</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="font-medium">{{ status.nozzle_temp|int }}</span>
|
||||||
|
<span class="text-slate-400">/</span>
|
||||||
|
<form class="flex items-center"
|
||||||
|
hx-post="/set-temperature/{{ printer.id }}"
|
||||||
|
hx-trigger="blur from:input, keyup[key=='Enter'] from:input"
|
||||||
|
hx-swap="none"
|
||||||
|
onsubmit="event.preventDefault();">
|
||||||
|
<input type="hidden" name="type" value="nozzle">
|
||||||
|
<input type="number"
|
||||||
|
name="value"
|
||||||
|
class="w-[3ch] font-medium bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 focus:rounded"
|
||||||
|
value="{{ status.nozzle_target|default(status.nozzle_temp|int) }}"
|
||||||
|
step="1"
|
||||||
|
onblur="if(!this.value) { this.value = '0'; this.form.requestSubmit(); }"
|
||||||
|
maxlength="3">
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div class="text-sm text-slate-400">Progress</div>
|
|
||||||
<div class="font-medium">{{ status.percentage }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div class="text-sm text-slate-400">Remaining Time</div>
|
<div class="flex justify-between items-center">
|
||||||
<div class="font-medium">{{ status.remaining_time }}</div>
|
<span class="text-sm text-slate-400">Bed Temp</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="font-medium">{{ status.bed_temp|int }}</span>
|
||||||
|
<span class="text-slate-400">/</span>
|
||||||
|
<form class="flex items-center"
|
||||||
|
hx-post="/set-temperature/{{ printer.id }}"
|
||||||
|
hx-trigger="blur from:input, keyup[key=='Enter'] from:input"
|
||||||
|
hx-swap="none"
|
||||||
|
onsubmit="event.preventDefault();">
|
||||||
|
<input type="hidden" name="type" value="bed">
|
||||||
|
<input type="number"
|
||||||
|
name="value"
|
||||||
|
class="w-[3ch] font-medium bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 focus:rounded"
|
||||||
|
value="{{ status.bed_target|default(status.bed_temp|int) }}"
|
||||||
|
step="1"
|
||||||
|
onblur="if(!this.value) { this.value = '0'; this.form.requestSubmit(); }"
|
||||||
|
maxlength="3">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-slate-400">Chamber Temp</span>
|
||||||
|
<span class="font-medium">{{ status.chamber_temp }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-slate-400">Progress</span>
|
||||||
|
<span class="font-medium">{{ status.percentage }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-slate-400">Time Left</span>
|
||||||
|
<span class="font-medium">{{ status.remaining_time }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-slate-400">Est. Done</span>
|
||||||
|
<span class="font-medium">{{ status.finish_time }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div class="text-sm text-slate-400">Time Done</div>
|
|
||||||
<div class="font-medium">{{ status.finish_time }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -10,9 +10,21 @@
|
|||||||
|
|
||||||
<!-- HTMX: lets you call endpoints and swap HTML fragments, no custom JS needed -->
|
<!-- HTMX: lets you call endpoints and swap HTML fragments, no custom JS needed -->
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Handle messages from server
|
||||||
|
document.body.addEventListener('showMessage', function(event) {
|
||||||
|
const message = event.detail;
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'fixed top-4 right-4 bg-red-500 text-white px-6 py-3 rounded shadow-lg';
|
||||||
|
div.textContent = message;
|
||||||
|
document.body.appendChild(div);
|
||||||
|
setTimeout(() => div.remove(), 3000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-900 text-slate-100">
|
<body class="bg-slate-900 text-slate-100">
|
||||||
<div class="max-w-3xl mx-auto p-6 bg-slate-800 rounded-lg shadow-md">
|
<div class="w-11/12 max-w-[90%] lg:max-w-[95%] mx-auto p-6 bg-slate-800 rounded-lg shadow-md">
|
||||||
<h1 class="text-2xl font-bold mb-4 text-white">X1 Carbon Connection</h1>
|
<h1 class="text-2xl font-bold mb-4 text-white">X1 Carbon Connection</h1>
|
||||||
{% block connect %}{% endblock %}
|
{% block connect %}{% endblock %}
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
@@ -1,52 +1,47 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block connect %}
|
{% block connect %}
|
||||||
|
|
||||||
<!-- Run action button -->
|
<div class="container mx-auto px-4 py-6">
|
||||||
<div id="connect-Button">
|
<!-- Connect All Button -->
|
||||||
{% if connected %}
|
<div class="max-w-3xl mx-auto mb-8">
|
||||||
<form
|
<form
|
||||||
hx-post="/disconnect"
|
hx-post="/connect-all"
|
||||||
hx-target="#connect-Button"
|
hx-target="#printers-container"
|
||||||
hx-swap="outerHTML"
|
hx-swap="innerHTML"
|
||||||
>
|
>
|
||||||
<button class="px-4 py-2 rounded bg-green-600 text-white hover:bg-red-600">
|
<button class="w-full px-6 py-3 rounded bg-indigo-600 text-white hover:bg-indigo-500 font-bold text-lg">
|
||||||
Printer is connected. (Press to disconnect)
|
Connect All Printers
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Live status panel, will load once when revealed and then the returned fragment polls itself -->
|
<!-- Printer Grid Container -->
|
||||||
<div id="live-status" hx-post="/status" hx-trigger="load" hx-swap="outerHTML" class="mt-4"></div>
|
<div id="printers-container" class="w-full">
|
||||||
|
<div class="grid gap-6 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
|
{% for printer in printers.values() %}
|
||||||
|
<div class="h-fit">
|
||||||
|
{% include "_printer_card.html" %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form
|
<div class="col-span-full text-center text-slate-400">
|
||||||
hx-post="/connect"
|
No printers configured. Check config.json file.
|
||||||
hx-target="#connect-Button"
|
</div>
|
||||||
hx-swap="outerHTML"
|
{% endfor %}
|
||||||
>
|
</div>
|
||||||
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-500">
|
|
||||||
Connect to Printer
|
<!-- Debug info -->
|
||||||
</button>
|
<div class="mt-8 text-sm text-slate-500">
|
||||||
</form>
|
Total Printers: {{ printers|length }}
|
||||||
{% endif %}
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
{% endblock %}
|
/* Ensure grid items don't overlap at any screen size */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
{% block status %}
|
#printers-container .grid {
|
||||||
<!--
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
<form
|
}
|
||||||
hx-post="/status"
|
}
|
||||||
hx-target="#status-result"
|
</style>
|
||||||
hx-swap="innerHTML"
|
|
||||||
>
|
|
||||||
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-500">
|
|
||||||
Get Printer Status
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div id="status-result" class="mt-4"></div>
|
|
||||||
-->
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
image: fastapi-htmx:dev
|
image: print-manager:dev
|
||||||
container_name: fastapi-htmx
|
container_name: print-manager
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
- "8883:8883" # MQTT over TLS
|
- "8883:8883" # MQTT over TLS
|
||||||
|
|||||||
Reference in New Issue
Block a user