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,76 +67,301 @@ 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)
async def connect_all(request: Request):
# Try to connect all printers
@app.post("/connect", response_class=HTMLResponse) for printer_instance in printers.values():
def connect(request: Request): try:
global connectionState if not printer_instance.connected:
try: success = printer_instance.printer.mqtt_start()
printer.mqtt_start() if success:
if(printer.mqtt_start()): printer_instance.connected = True
connectionState = True except Exception:
except Exception: continue # Skip failed connections and continue with others
connectionState = False
# Render the updated container contents
# Return the connect area so HTMX can swap the button immediately html_content = templates.get_template("_printer_grid.html").render(
return templates.TemplateResponse("_connect_area.html", {"request": request, "connected": connectionState}) request=request,
printers=printers
)
@app.post("/disconnect", response_class=HTMLResponse) return HTMLResponse(content=html_content)
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"
@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 # 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,37 +1,104 @@
<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>
<div class="text-sm text-slate-400">Progress</div> <div class="flex justify-between items-center">
<div class="font-medium">{{ status.percentage }}</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>
<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">Chamber Temp</span>
<span class="font-medium">{{ status.chamber_temp }}</span>
</div> </div>
<div>
<div class="text-sm text-slate-400">Time Done</div> <div class="flex justify-between items-center">
<div class="font-medium">{{ status.finish_time }}</div> <span class="text-sm text-slate-400">Progress</span>
<span class="font-medium">{{ status.percentage }}</span>
</div> </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>
{% endif %} {% endif %}
</div> </div>

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" 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">
{% else %} <div class="grid gap-6 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<form {% for printer in printers.values() %}
hx-post="/connect" <div class="h-fit">
hx-target="#connect-Button" {% include "_printer_card.html" %}
hx-swap="outerHTML" </div>
> {% else %}
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-500"> <div class="col-span-full text-center text-slate-400">
Connect to Printer No printers configured. Check config.json file.
</button> </div>
</form> {% endfor %}
{% endif %} </div>
<!-- Debug info -->
<div class="mt-8 text-sm text-slate-500">
Total Printers: {{ printers|length }}
</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 %}

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