More work on the management UI, multi printer is working, temps is sometimes working

This commit is contained in:
2025-10-24 17:18:57 +11:00
parent cf5edd1329
commit 41431277cf
12 changed files with 679 additions and 169 deletions

View 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

View 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"
}
]
}

View File

@@ -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
})

View File

@@ -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>

View 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>

View 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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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"
>
<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="load" 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>
{% endblock %}
{% block status %}
<!--
<form
hx-post="/status"
hx-target="#status-result"
hx-swap="innerHTML" hx-swap="innerHTML"
> >
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-500"> <button class="w-full px-6 py-3 rounded bg-indigo-600 text-white hover:bg-indigo-500 font-bold text-lg">
Get Printer Status Connect All Printers
</button> </button>
</form> </form>
<div id="status-result" class="mt-4"></div> </div>
-->
<!-- Printer Grid Container -->
<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 %}
<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>
</div>
</div>
<style>
/* Ensure grid items don't overlap at any screen size */
@media (min-width: 1024px) {
#printers-container .grid {
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
}
}
</style>
{% endblock %} {% endblock %}

View File

@@ -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