diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
deleted file mode 100644
index df73862..0000000
--- a/.gitlab-ci.yml
+++ /dev/null
@@ -1,37 +0,0 @@
-stages:
- - build
- - push
-
-build:
- stage: build
- image:
- name: gcr.io/kaniko-project/executor:debug
- entrypoint: [""]
- only:
- - master
- script:
- - /kaniko/executor
- --cache=true
- --context "${CI_PROJECT_DIR}"
- --dockerfile "${CI_PROJECT_DIR}/Dockerfile"
- --destination "dcr.faked.org/chitui:${CI_COMMIT_SHORT_SHA}"
-
-push_latest:
- stage: push
- image:
- name: gcr.io/go-containerregistry/crane:debug
- entrypoint: [""]
- only:
- - master
- script:
- - crane copy dcr.faked.org/chitui:${CI_COMMIT_SHORT_SHA} dcr.faked.org/chitui:latest
-
-push_tag:
- stage: push
- image:
- name: gcr.io/go-containerregistry/crane:debug
- entrypoint: [""]
- only:
- - tags
- script:
- - crane copy dcr.faked.org/chitui:${CI_COMMIT_SHORT_SHA} dcr.faked.org/chitui:${CI_COMMIT_TAG}
diff --git a/Dockerfile b/Dockerfile
index f5f3519..cf624cd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,12 +1,38 @@
-FROM python:3.12-alpine
+FROM python:3.10-slim
WORKDIR /app
-RUN pip install gevent==24.2.1 gevent-websocket==0.10.1
+# Install system dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc \
+ libc-dev \
+ libffi-dev \
+ net-tools \
+ iputils-ping \
+ iproute2 \
+ && rm -rf /var/lib/apt/lists/*
+# Install Python dependencies
COPY requirements.txt .
-RUN pip install -r requirements.txt
+RUN pip install --no-cache-dir -r requirements.txt
+# Optional: Install netifaces for better network interface detection
+RUN pip install --no-cache-dir netifaces
+
+# Create necessary directories
+RUN mkdir -p /app/uploads /app/logs /app/config /app/backups /app/data
+
+# Copy application code
COPY . .
-ENTRYPOINT ["python", "main.py"]
+# Set environment variables
+ENV PORT=54780
+ENV HOST=0.0.0.0
+ENV DEBUG=false
+ENV DISCOVERY_TIMEOUT=3
+
+# Expose the application port
+EXPOSE 54780
+
+# Run the application
+ENTRYPOINT ["python", "main.py", "run"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 187e7c5..fee395d 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,308 @@
# ChitUI
-A web UI for Chitubox SDCP 3.0 resin printers
+A modern web UI for managing Chitubox SDCP 3.0-compatible resin 3D printers.
-## Setup
+## β¨ Features
+
+- **π Smart Discovery** - Automatically detect SDCP-compatible printers on your local network
+- **π Real-time Monitoring** - Track print progress, status, and parameters with live updates
+- **π File Management** - Upload, organize and manage print files directly from the web interface
+- **π· Camera Integration** - Live stream from printers with built-in cameras (like Elegoo Saturn 4 Ultra)
+- **π±οΈ Remote Control** - Start, pause, resume and stop prints from anywhere on your network
+- **π₯ Multi-user Support** - Role-based authentication with admin and user privileges
+- **π Dark/Light Themes** - Modern, responsive UI with automatic and manual theme switching
+- **π± Mobile Friendly** - Control your printers from any device with a web browser
+- **π¦ Docker Ready** - Easy deployment using Docker with host network support
+
+
+
+## πΌοΈ Screenshots
+
+
+
+
+
+
+
+## π Installation
+
+### Prerequisites
+
+- Python 3.10 or newer
+- Network access to your SDCP-compatible 3D printers
+- For camera functionality: working cameras on your printers
+
+### Method 1: Standard Installation
+
+1. **Clone the repository**:
+ ```bash
+ git clone https://github.com/yourusername/chitui.git
+ cd chitui
+ ```
+
+2. **Create and activate a virtual environment**:
+ ```bash
+ python -m venv .venv
+
+ # On Windows:
+ .venv\Scripts\activate
+
+ # On Linux/Mac:
+ source .venv/bin/activate
+ ```
+
+3. **Install dependencies**:
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+4. **Initialize the database**:
+ ```bash
+ python main.py init-db
+ ```
+
+5. **Configure settings** (optional):
+ ```bash
+ cp config/default.yaml config/config.yaml
+ # Edit config.yaml with your preferred settings
+ ```
+
+6. **Launch the application**:
+ ```bash
+ python main.py
+ ```
+
+ By default, ChitUI will be available at `http://localhost:54780` with the default login credentials (admin/admin).
+
+### Method 2: Docker Installation
+
+#### Using Docker Run:
+
+```bash
+docker build -t chitui:latest .
+docker run --rm --name chitui --net=host \
+ -v ./config:/app/config \
+ -v ./uploads:/app/uploads \
+ -v ./logs:/app/logs \
+ -v ./data:/app/data \
+ -e ADMIN_PASSWORD=yourpassword \
+ chitui:latest
```
-python -mvenv .venv
-source .venv/bin/activate
-pip install -r requirements.txt
+
+#### Using Docker Compose:
+
+1. **Create docker-compose.yml**:
+ ```yaml
+ version: '3.8'
+
+ services:
+ chitui:
+ build: .
+ image: chitui:latest
+ container_name: chitui
+ network_mode: host
+ volumes:
+ - ./config:/app/config
+ - ./uploads:/app/uploads
+ - ./logs:/app/logs
+ - ./data:/app/data
+ environment:
+ - PORT=54780
+ - HOST=0.0.0.0
+ - DEBUG=false
+ - LOG_LEVEL=INFO
+ - ADMIN_USER=admin
+ - ADMIN_PASSWORD=yourpassword
+ restart: unless-stopped
+ ```
+
+2. **Launch with docker-compose**:
+ ```bash
+ docker-compose up -d
+ ```
+
+> **β οΈ Important Note:** ChitUI requires host networking for printer discovery to work properly. This is because it needs to broadcast UDP packets on your local network to find printers.
+
+## π Usage
+
+### Command Line Options
+
```
+ChitUI - Web UI for Chitubox SDCP 3.0 resin printers
-## Usage
-After creating the virtual environment and installing the requirements, you can run ChitUI like this:
+Options:
+ -c, --config PATH Path to configuration file
+ -h, --host TEXT Host to bind the server to
+ -p, --port INTEGER Port to bind the server to
+ -d, --debug Enable debug mode
+ -l, --log-level TEXT Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
+ --db, --database TEXT Database URI (e.g., sqlite:///chitui.db)
+ --help Show this message and exit.
+
+Commands:
+ backup-db Backup the database to a file
+ init-db Initialize or upgrade the database
+ run Run the ChitUI web server (default)
```
-python main.py
+
+### Database Management
+
+#### Database Configuration
+
+ChitUI supports multiple database backends:
+
+1. **SQLite** (default):
+ ```yaml
+ database_uri: "sqlite:///chitui.db"
+ ```
+
+2. **MySQL**:
+ ```yaml
+ database_uri: "mysql+pymysql://username:password@localhost/chitui"
+ ```
+
+3. **PostgreSQL**:
+ ```yaml
+ database_uri: "postgresql+psycopg2://username:password@localhost/chitui"
+ ```
+
+#### Database Backup
+
+Create a database backup:
+```bash
+python main.py backup-db
```
-and then access the web interface on port 54780, e.g. http://127.0.0.1:54780/
-## Docker
-As ChitUI needs to broadcast UDP messages on your network segment, running ChitUI in a Docker container requires host networking to be enabled for the container:
+Specify a custom backup location:
+```bash
+python main.py backup-db --backup-dir /path/to/backups
```
-docker build -t chitui:latest .
-docker run --rm --name chitui --net=host chitui:latest
+
+## βοΈ Configuration
+
+### Configuration File
+
+ChitUI can be configured using a YAML configuration file at `config/config.yaml`:
+
+```yaml
+# Network settings
+host: "0.0.0.0"
+port: 54780
+
+# Application settings
+debug: false
+log_level: "INFO"
+upload_folder: "uploads"
+log_folder: "logs"
+
+# Database settings
+database_uri: "sqlite:///chitui.db"
+
+# User settings
+admin_user: "admin"
+admin_password: "admin"
```
-## Configuration
-Configuration is done via environment variables:
-* `PORT` to set the HTTP port of the web interface (default: `54780`)
-* `DEBUG` to enable debug logging, log colorization and code reloading (default: `False`)
+### Environment Variables
+
+You can also configure ChitUI using environment variables:
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `HOST` | Host address to bind to | "0.0.0.0" |
+| `PORT` | Port to listen on | 54780 |
+| `DEBUG` | Enable debug mode | false |
+| `LOG_LEVEL` | Logging level | "INFO" |
+| `UPLOAD_FOLDER` | Directory for temporary file uploads | "uploads" |
+| `LOG_FOLDER` | Directory for log files | "logs" |
+| `ADMIN_USER` | Default admin username | "admin" |
+| `ADMIN_PASSWORD` | Default admin password | "admin" |
+| `DATABASE_URI` | Database connection URI | "sqlite:///chitui.db" |
+
+## π¨οΈ Supported Printers
+
+ChitUI is compatible with printers that support the Chitubox SDCP 3.0 protocol, including:
+
+### Elegoo Saturn Series
+- Saturn Ultra 16K
+- Saturn 4 Ultra
+- Saturn 4
+- Saturn 3 Ultra
+- Saturn 3
+- Saturn 2
+- Saturn
+
+### Elegoo Mars Series
+- Mars 4 Ultra
+- Mars 4
+- Mars 3 Ultra
+- Mars 3
+- Mars 2
+
+### Other Manufacturers
+- Any printer that supports the SDCP 3.0 protocol
+
+> **π· Camera Support**: Live camera streaming is available on models with built-in cameras, such as the Saturn 4 Ultra, Saturn Ultra 16K, and others.
+
+## π Troubleshooting
+
+### Common Issues
+
+1. **Cannot discover printers**:
+ - Make sure your printers are on the same network as ChitUI
+ - Check if UDP port 3000 is not blocked by your firewall
+ - Try adding printers manually using their IP addresses
+
+2. **Camera streaming not working**:
+ - Verify that your printer model has a built-in camera
+ - Ensure the printer firmware is up to date
+ - Check if the camera is enabled in the printer settings
+
+3. **File uploads failing**:
+ - Check the ChitUI logs for detailed error information
+ - Ensure the file type is supported (.ctb, .goo, .prz)
+ - Verify that the uploads directory is writable
+
+### Getting Help
+
+- Check the logs in the `logs` directory for more detailed error information
+- File an issue on the GitHub repository if you encounter a bug
+- Join our Discord server for community support
+
+## π€ Contributing
+
+Contributions are welcome! Here's how you can help:
+
+1. **Fork the repository**
+2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
+3. **Commit your changes**: `git commit -m 'Add some amazing feature'`
+4. **Push to the branch**: `git push origin feature/amazing-feature`
+5. **Open a Pull Request**
+
+### Development Setup
+
+1. Clone the repository and set up a virtual environment as described in the installation section
+2. Install development dependencies:
+ ```bash
+ pip install -r requirements-dev.txt
+ ```
+3. Run tests:
+ ```bash
+ pytest
+ ```
+
+## π License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## π§ Contact
+
+- GitHub Issues: [https://github.com/antoinebou12/chitui/issues](https://github.com/yourusername/chitui/issues)
+- Email: your.email@example.com
+- Discord: [Join our server](https://discord.gg/yourlink)
+
+---
+
+
+Made with β€οΈ for the 3D printing community
+
diff --git a/TODO.txt b/TODO.txt
index 5900429..9f663f1 100644
--- a/TODO.txt
+++ b/TODO.txt
@@ -1,3 +1 @@
-* manual adding of printers
* camera frame/stream
-* print job control
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..cabfad2
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""
+ChitUI application initialization
+"""
+import os
+import time
+import uuid
+from datetime import datetime
+
+from flask import Flask, current_app
+from flask_socketio import SocketIO
+from flask_login import LoginManager
+from flask_migrate import Migrate
+from flask_swagger_ui import get_swaggerui_blueprint
+from loguru import logger
+from werkzeug.security import generate_password_hash
+from apscheduler.schedulers.background import BackgroundScheduler
+
+# Initialize extensions
+socketio = SocketIO()
+login_manager = LoginManager()
+migrate = Migrate()
+sched = BackgroundScheduler()
+
+# Runtime state
+printers = {}
+websockets = {}
+upload_progress = {}
+
+from app.models import JobStatus, db, User, PrintJob, SystemSetting
+from app.printer_manager import start_print # needed for scheduler
+
+def create_app(config=None):
+ """Create and configure the Flask application."""
+ base = os.path.abspath(os.path.dirname(__file__))
+ app = Flask(
+ __name__,
+ static_folder=os.path.join(base, '..', 'static'),
+ static_url_path='/static',
+ template_folder=os.path.join(base, '..', 'templates'),
+ )
+
+ # ββ Load and normalize config ββββββββββββββββββββββββββββββββββββββββββββ
+ # your YAML loader / env loader should populate `config` with at least:
+ # host, port, debug, log_folder, upload_folder, secret_key, database_uri, admin_user, admin_password, etc.
+ app.config['SECRET_KEY'] = config.get('secret_key', os.urandom(24).hex())
+ app.config['UPLOAD_FOLDER'] = config.get('upload_folder', 'uploads')
+ app.config['MAX_CONTENT_LENGTH'] = config.get('max_upload_size_bytes', 1024**3)
+ app.config['DEBUG'] = config.get('debug', False)
+
+ # ββ FIXED: Ensure we write our app log into the configured log_folder βββββ
+ log_folder = config.get('log_folder', 'logs')
+ os.makedirs(log_folder, exist_ok=True)
+ app.config['LOG_FILE'] = os.path.join(log_folder, 'chitui.log')
+
+ # ββ Database βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ app.config['SQLALCHEMY_DATABASE_URI'] = config.get('database_uri', 'sqlite:///chitui.db')
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
+ db.init_app(app)
+ migrate.init_app(app, db)
+
+ # ββ Login manager βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ login_manager.init_app(app)
+ login_manager.login_view = 'auth.login'
+ login_manager.login_message_category = 'info'
+
+ # ββ Blueprints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ from app.routes import routes_bp
+ from app.auth import auth_bp
+ from app.api import api_bp
+
+ app.register_blueprint(routes_bp)
+ app.register_blueprint(auth_bp, url_prefix='/auth')
+ app.register_blueprint(api_bp, url_prefix='/api')
+
+ # ββ Swagger UI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ swagger_bp = get_swaggerui_blueprint(
+ '/swagger',
+ '/api/swagger.json',
+ config={'app_name': "ChitUI API"}
+ )
+ app.register_blueprint(swagger_bp, url_prefix='/swagger')
+
+ # ββ Socket.IO ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ socketio.init_app(app, async_mode='gevent', cors_allowed_origins="*")
+ from app.socket_handlers import register_handlers
+ register_handlers(socketio)
+
+ # ββ On first launch, create tables, admin user, system settings, schedule jobs β
+ with app.app_context():
+ db.create_all()
+ _create_admin_user(config)
+ _load_system_settings(config)
+ _schedule_existing_jobs()
+ # Store a single startup timestamp so health/u
+ current_app.config.setdefault('_start_time', datetime.utcnow().timestamp())
+
+ if not sched.running:
+ sched.start()
+
+ @login_manager.user_loader
+ def load_user(user_id):
+ return User.query.get(user_id)
+
+ app.config['_start_time'] = time.time()
+
+ logger.info("Application initialized")
+ return app, socketio
+
+
+def _create_admin_user(config):
+ if not config.get('admin_user') or not config.get('admin_password'):
+ return
+ admin = User.query.filter_by(username=config['admin_user']).first()
+ if not admin:
+ admin = User(
+ id=str(uuid.uuid4()),
+ username=config['admin_user'],
+ password=generate_password_hash(config['admin_password']),
+ role='admin'
+ )
+ db.session.add(admin)
+ db.session.commit()
+ logger.info(f"Admin user created: {config['admin_user']}")
+ else:
+ logger.info(f"Admin user exists: {config['admin_user']}")
+
+
+def _load_system_settings(config):
+ """Load system settings from database or create defaults."""
+ default_settings = {
+ 'discovery_timeout': {'value': config.get('discovery_timeout', 1), 'type': 'int'},
+ 'host': {'value': config.get('host', '0.0.0.0'), 'type': 'string'},
+ 'port': {'value': config.get('port', 54780), 'type': 'int'},
+ 'debug': {'value': config.get('debug', False), 'type': 'bool'},
+ 'log_level': {'value': config.get('log_level', 'INFO'), 'type': 'string'},
+ 'uploads_enabled': {'value': True, 'type': 'bool'},
+ 'auto_discovery': {'value': True, 'type': 'bool'},
+ 'last_backup': {'value': None, 'type': 'string'},
+ 'version': {'value': '1.0.0', 'type': 'string'},
+ 'startup_time': {'value': datetime.utcnow().isoformat(), 'type': 'string'}
+ }
+ for key, setting in default_settings.items():
+ db_setting = SystemSetting.query.get(key)
+ if not db_setting:
+ db_setting = SystemSetting(key=key, type=setting['type'])
+ db_setting.set_value(setting['value'])
+ db.session.add(db_setting)
+ elif key in ['version', 'startup_time']:
+ db_setting.set_value(setting['value'])
+ db.session.commit()
+ logger.info("System settings loaded")
+
+def _schedule_existing_jobs():
+ for job in PrintJob.query.filter_by(status=JobStatus.QUEUED).all():
+ sched.add_job(
+ func=start_print,
+ trigger='date',
+ run_date=job.scheduled,
+ args=[job.printer_id, job.filename],
+ id=str(job.id),
+ )
+ logger.info("Existing print jobs scheduled")
diff --git a/app/api.py b/app/api.py
new file mode 100644
index 0000000..6e064f9
--- /dev/null
+++ b/app/api.py
@@ -0,0 +1,780 @@
+# app/api.py
+
+from __future__ import annotations
+import os
+import time
+import uuid
+from datetime import datetime
+from typing import Dict, Any
+
+import requests
+from flask import Blueprint, current_app, request, jsonify, abort, Response
+from flask_login import current_user, login_required
+from loguru import logger
+from marshmallow import Schema, fields, ValidationError
+
+from app import printers, sched, upload_progress
+from app.models import JobStatus, PrintJob, db
+from app.printer_manager import (
+ add_printer_manually,
+ debug_printer_connection,
+ delete_file,
+ get_printer_files,
+ pause_print,
+ rename_printer,
+ resume_print,
+ set_camera_status,
+ start_print,
+ stop_print,
+ queue_print,
+ remove_printer
+)
+
+# βββ Blueprint ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+api_bp = Blueprint('api', __name__)
+
+
+# βββ Schemas ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+class AddPrinterSchema(Schema):
+ name = fields.String(required=True)
+ ip = fields.IP(required=True)
+ model = fields.String(missing="unknown")
+ brand = fields.String(missing="unknown")
+
+
+class PrintRequestSchema(Schema):
+ filename = fields.String(required=True)
+ queue = fields.Boolean(missing=False)
+
+
+class DiagnoseSchema(Schema):
+ ip = fields.IP(required=True)
+
+
+class JobCreateSchema(Schema):
+ printer_id = fields.String(required=True)
+ filename = fields.String(required=True)
+ datetime = fields.DateTime(required=True)
+
+
+# βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+def make_success(data: Any = None) -> Any:
+ payload = {"success": True}
+ if data is not None:
+ payload["data"] = data
+ return jsonify(payload)
+
+
+def make_error(code: str, message: str, http_status: int = 400) -> Any:
+ return jsonify({"success": False, "error": {"code": code, "message": message}}), http_status
+
+
+def get_last_log_lines(log_path: str, num_lines: int = 500) -> list[str]:
+ """
+ Read the last `num_lines` from the given log file.
+ Raises FileNotFoundError if the file does not exist.
+ """
+ if not os.path.exists(log_path):
+ raise FileNotFoundError(f"Log file not found at {log_path}")
+ with open(log_path, 'r') as f:
+ # read all lines and slice
+ lines = f.readlines()
+ return lines[-num_lines:]
+
+
+# βββ Swagger JSON βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@api_bp.route('/swagger.json')
+def swagger_json():
+ return jsonify({
+ "swagger": "2.0",
+ "info": {
+ "title": "ChitUI API",
+ "description": "API for controlling 3D printers with ChitUI",
+ "version": "1.0.0"
+ },
+ "basePath": "/api",
+ "schemes": ["http", "https"],
+ "consumes": ["application/json", "application/x-www-form-urlencoded"],
+ "produces": ["application/json"],
+ "tags": [
+ { "name": "Authentication", "description": "User login/logout" },
+ { "name": "Printers", "description": "Printer management" },
+ { "name": "PrintJobs", "description": "Job scheduling" },
+ { "name": "System", "description": "System info & logs" }
+ ],
+ "paths": {
+ "/auth/login": {
+ "post": {
+ "tags": ["Authentication"],
+ "summary": "User login",
+ "description": "Authenticate with username & password (sets session cookie)",
+ "consumes": ["application/x-www-form-urlencoded"],
+ "parameters": [
+ {
+ "in": "formData",
+ "name": "username",
+ "type": "string",
+ "required": True,
+ "description": "Your username"
+ },
+ {
+ "in": "formData",
+ "name": "password",
+ "type": "string",
+ "required": True,
+ "description": "Your password"
+ },
+ {
+ "in": "formData",
+ "name": "remember",
+ "type": "boolean",
+ "required": False,
+ "description": "Stay logged in (optional)"
+ }
+ ],
+ "responses": {
+ "200": { "description": "Login successful" },
+ "401": { "description": "Invalid credentials" }
+ }
+ }
+ },
+ "/auth/logout": {
+ "get": {
+ "tags": ["Authentication"],
+ "summary": "User logout",
+ "description": "Clear session and log out",
+ "responses": {
+ "302": { "description": "Redirect to login page" }
+ }
+ }
+ },
+ "/printer/list": {
+ "get": {
+ "tags": ["Printers"],
+ "summary": "Get list of printers",
+ "responses": { "200": { "description": "Success" } }
+ }
+ },
+ "/printer/discover": {
+ "post": {
+ "tags": ["Printers"],
+ "summary": "Discover printers",
+ "responses": { "200": { "description": "Discovery initiated" } }
+ }
+ },
+ "/printer/{id}": {
+ "get": {
+ "tags": ["Printers"],
+ "summary": "Get printer details",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" }
+ ],
+ "responses": {
+ "200": { "description": "Printer details" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+ "/printer/add": {
+ "post": {
+ "tags": ["Printers"],
+ "summary": "Add printer manually",
+ "parameters": [
+ {
+ "in": "body", "name": "body", "required": True,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "ip": { "type": "string" },
+ "model": { "type": "string" },
+ "brand": { "type": "string" }
+ },
+ "required": ["name", "ip"]
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "Printer added" },
+ "400": { "description": "Invalid input" },
+ "500": { "description": "Failed to add" }
+ }
+ }
+ },
+ "/printer/{id}/files": {
+ "get": {
+ "tags": ["Printers"],
+ "summary": "List files",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" },
+ { "name": "path","in": "query", "required": False, "type": "string", "default": "/local" }
+ ],
+ "responses": {
+ "200": { "description": "File list" },
+ "404": { "description": "Printer not found" }
+ }
+ }
+ },
+ "/printer/{id}/print": {
+ "post": {
+ "tags": ["PrintJobs"],
+ "summary": "Start or queue a print",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" },
+ {
+ "in": "body", "name": "body", "required": True,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "filename": { "type": "string" },
+ "queue": { "type": "boolean", "default": False }
+ },
+ "required": ["filename"]
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "Print started or queued" },
+ "404": { "description": "Printer or file not found" }
+ }
+ }
+ },
+ "/printer/{id}/pause": {
+ "post": {
+ "tags": ["PrintJobs"],
+ "summary": "Pause print",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" }
+ ],
+ "responses": {
+ "200": { "description": "Paused" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+ "/printer/{id}/resume": {
+ "post": {
+ "tags": ["PrintJobs"],
+ "summary": "Resume print",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" }
+ ],
+ "responses": {
+ "200": { "description": "Resumed" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+ "/printer/{id}/stop": {
+ "post": {
+ "tags": ["PrintJobs"],
+ "summary": "Stop print",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" }
+ ],
+ "responses": {
+ "200": { "description": "Stopped" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+ "/printer/{id}/camera": {
+ "post": {
+ "tags": ["Printers"],
+ "summary": "Enable/disable camera",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" },
+ {
+ "in": "body", "name": "body", "required": True,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "enable": { "type": "boolean" }
+ },
+ "required": ["enable"]
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "Updated" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+ "/printer/{id}/camera/status": {
+ "get": {
+ "tags": ["Printers"],
+ "summary": "Get camera status",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" }
+ ],
+ "responses": {
+ "200": { "description": "Camera status" },
+ "400": { "description": "Not supported" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+ "/printer/{id}/delete": {
+ "post": {
+ "tags": ["Printers"],
+ "summary": "Delete file",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" },
+ {
+ "in": "body", "name": "body", "required": True,
+ "schema": {
+ "type": "object",
+ "properties": { "filename": { "type": "string" } },
+ "required": ["filename"]
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "Deleted" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+ "/printer/{id}/rename": {
+ "post": {
+ "tags": ["Printers"],
+ "summary": "Rename printer",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" },
+ {
+ "in": "body", "name": "body", "required": True,
+ "schema": {
+ "type": "object",
+ "properties": { "name": { "type": "string" } },
+ "required": ["name"]
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "Renamed" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+ "/printer/diagnostics": {
+ "post": {
+ "tags": ["System"],
+ "summary": "Run diagnostics",
+ "parameters": [
+ {
+ "in": "body", "name": "body", "required": True,
+ "schema": {
+ "type": "object",
+ "properties": { "ip": { "type": "string" } },
+ "required": ["ip"]
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "Results" },
+ "400": { "description": "Invalid IP" }
+ }
+ }
+ },
+ "/camera/{id}/frame": {
+ "get": {
+ "tags": ["System"],
+ "summary": "Single camera frame",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" }
+ ],
+ "responses": {
+ "200": { "description": "JPEG" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+ "/camera/{id}/stream": {
+ "get": {
+ "tags": ["System"],
+ "summary": "Camera MJPEG stream",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" }
+ ],
+ "responses": {
+ "200": { "description": "MJPEG" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+ "/uploads": {
+ "get": {
+ "tags": ["System"],
+ "summary": "List uploads",
+ "responses": { "200": { "description": "Upload tasks" } }
+ }
+ },
+ "/jobs": {
+ "get": {
+ "tags": ["PrintJobs"],
+ "summary": "List jobs",
+ "parameters": [
+ { "name": "history", "in": "query", "required": False, "type": "boolean" }
+ ],
+ "responses": { "200": { "description": "Scheduled jobs" } }
+ },
+ "post": {
+ "tags": ["PrintJobs"],
+ "summary": "Create job",
+ "parameters": [
+ {
+ "in": "body", "name": "body", "required": True,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "printer_id": { "type": "string" },
+ "filename": { "type": "string" },
+ "datetime": { "type": "string" }
+ },
+ "required": ["printer_id", "filename", "datetime"]
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "Job created" },
+ "400": { "description": "Bad request" }
+ }
+ }
+ },
+ "/printer/{id}/remove": {
+ "post": {
+ "tags": ["Printers"],
+ "summary": "Remove printer",
+ "parameters": [
+ { "name": "id", "in": "path", "required": True, "type": "string" }
+ ],
+ "responses": {
+ "200": { "description": "Removed" },
+ "404": { "description": "Not found" },
+ "500": { "description": "Failed" }
+ }
+ }
+ },
+ "/logs": {
+ "get": {
+ "tags": ["System"],
+ "summary": "Get last N log lines",
+ "responses": {
+ "200": { "description": "Log lines" },
+ "404": { "description": "No log file" }
+ }
+ }
+ },
+ "/health": {
+ "get": {
+ "tags": ["System"],
+ "summary": "API health & uptime",
+ "responses": { "200": { "description": "Health info" } }
+ }
+ }
+ }
+ })
+
+
+
+
+# βββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@api_bp.route("/printer/list")
+@login_required
+def list_printers():
+ return make_success({pid: dict(d) for pid, d in printers.items()})
+
+
+@api_bp.route('/printer/discover', methods=['POST'])
+@login_required
+def discover_printers():
+ """Kick off discovery in background."""
+ from app.socket_handlers import discovery_task
+ from threading import Thread
+ Thread(target=discovery_task, daemon=True).start()
+ return make_success({"message": "Discovery initiated"})
+
+
+@api_bp.route('/printer/')
+@login_required
+def get_printer(printer_id):
+ printer = printers.get(printer_id)
+ if not printer:
+ return make_error("NOT_FOUND", "Printer not found", 404)
+ return make_success(printer)
+
+
+@api_bp.route("/printer/add", methods=["POST"])
+@login_required
+def add_printer():
+ try:
+ payload = AddPrinterSchema().load(request.get_json(force=True))
+ except ValidationError as err:
+ return make_error("INVALID_INPUT", err.messages, 400)
+
+ printer = add_printer_manually(**payload)
+ if not printer:
+ return make_error("ADD_FAILED", f"Cannot reach printer at {payload['ip']}", 500)
+
+ logger.info(f"Printer added: {printer['name']} ({printer['ip']})")
+ return make_success({"printer_id": printer["id"]})
+
+
+@api_bp.route('/printer//files')
+@login_required
+def api_get_printer_files(printer_id):
+ if printer_id not in printers:
+ return make_error("NOT_FOUND", "Printer not found", 404)
+
+ path = request.args.get('path', '/local')
+ get_printer_files(printer_id, path)
+ files = printers[printer_id].get("files", {}).get(path, [])
+ return make_success(files)
+
+
+@api_bp.route('/printer//print', methods=['POST'])
+@login_required
+def api_print_file(printer_id):
+ if printer_id not in printers:
+ return make_error("NOT_FOUND", "Printer not found", 404)
+
+ try:
+ payload = PrintRequestSchema().load(request.get_json(force=True))
+ except ValidationError as err:
+ return make_error("INVALID_INPUT", err.messages, 400)
+
+ if payload["queue"]:
+ queue_print(printer_id, payload["filename"])
+ return make_success({"message": "File queued"})
+ else:
+ ok = start_print(printer_id, payload["filename"])
+ if not ok:
+ return make_error("PRINT_FAILED", "Failed to start print", 500)
+ return make_success({"message": "Print started"})
+
+
+@api_bp.route('/printer//pause', methods=['POST'])
+@login_required
+def api_pause_print(printer_id):
+ if printer_id not in printers:
+ return make_error("NOT_FOUND", "Printer not found", 404)
+ if not pause_print(printer_id):
+ return make_error("PAUSE_FAILED", "Failed to pause print", 500)
+ return make_success()
+
+
+@api_bp.route('/printer//resume', methods=['POST'])
+@login_required
+def api_resume_print(printer_id):
+ if printer_id not in printers:
+ return make_error("NOT_FOUND", "Printer not found", 404)
+ if not resume_print(printer_id):
+ return make_error("RESUME_FAILED", "Failed to resume print", 500)
+ return make_success()
+
+
+@api_bp.route('/printer//stop', methods=['POST'])
+@login_required
+def api_stop_print(printer_id):
+ if printer_id not in printers:
+ return make_error("NOT_FOUND", "Printer not found", 404)
+ if not stop_print(printer_id):
+ return make_error("STOP_FAILED", "Failed to stop print", 500)
+ return make_success()
+
+
+@api_bp.route('/printer//camera', methods=['POST'])
+@login_required
+def api_set_camera(printer_id):
+ if printer_id not in printers:
+ return make_error("NOT_FOUND", "Printer not found", 404)
+ try:
+ payload = DiagnoseSchema().load(request.get_json(force=True))
+ except ValidationError as err:
+ return make_error("INVALID_INPUT", err.messages, 400)
+
+ ok = set_camera_status(printer_id, payload["ip"])
+ if not ok:
+ return make_error("CAMERA_FAILED", "Failed to update camera", 500)
+ return make_success()
+
+
+@api_bp.route('/printer//delete', methods=['POST'])
+@login_required
+def api_delete_file(printer_id):
+ if printer_id not in printers:
+ return make_error("NOT_FOUND", "Printer not found", 404)
+ filename = request.json.get("filename")
+ if not filename:
+ return make_error("INVALID_INPUT", "Filename is required", 400)
+
+ if not delete_file(printer_id, filename):
+ return make_error("DELETE_FAILED", "Failed to delete file", 500)
+ return make_success()
+
+
+@api_bp.route('/printer//rename', methods=['POST'])
+@login_required
+def api_rename_printer(printer_id):
+ if printer_id not in printers:
+ return make_error("NOT_FOUND", "Printer not found", 404)
+ new_name = request.json.get("name")
+ if not new_name:
+ return make_error("INVALID_INPUT", "Name is required", 400)
+
+ if not rename_printer(printer_id, new_name):
+ return make_error("RENAME_FAILED", "Failed to rename printer", 500)
+ return make_success()
+
+
+@api_bp.route('/printer/diagnostics', methods=['POST'])
+@login_required
+def api_diagnostics():
+ try:
+ payload = DiagnoseSchema().load(request.get_json(force=True))
+ except ValidationError as err:
+ return make_error("INVALID_INPUT", err.messages, 400)
+
+ results = debug_printer_connection(payload["ip"])
+ return make_success(results)
+
+
+@api_bp.route("/camera//frame")
+@login_required
+def api_camera_frame(printer_id):
+ p = printers.get(printer_id)
+ if not p or "camera_config" not in p:
+ abort(404)
+ url = p["camera_config"]["snapshot"].format(ip=p["ip"])
+ r = requests.get(url, stream=True, timeout=5)
+ return Response(r.iter_content(4096), mimetype="image/jpeg")
+
+
+@api_bp.route("/camera//stream")
+@login_required
+def api_camera_stream(printer_id):
+ p = printers.get(printer_id)
+ if not p or "camera_config" not in p:
+ abort(404)
+ url = p["camera_config"]["mjpeg"].format(ip=p["ip"])
+
+ def gen():
+ with requests.get(url, stream=True, timeout=5) as r:
+ for chunk in r.iter_content(1024):
+ yield chunk
+ return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")
+
+
+@api_bp.route("/uploads")
+@login_required
+def api_list_uploads():
+ return make_success(upload_progress)
+
+
+@api_bp.route("/jobs", methods=["POST"])
+@login_required
+def api_create_job():
+ try:
+ payload = JobCreateSchema().load(request.get_json(force=True))
+ except ValidationError as err:
+ return make_error("INVALID_INPUT", err.messages, 400)
+
+ try:
+ job = PrintJob(
+ id=str(uuid.uuid4()),
+ printer_id=payload["printer_id"],
+ user_id=current_user.id,
+ filename=payload["filename"],
+ status=JobStatus.QUEUED,
+ scheduled=payload["datetime"],
+ )
+ db.session.add(job)
+ db.session.commit()
+
+ sched.add_job(
+ func=start_print,
+ trigger="date",
+ run_date=job.scheduled,
+ args=[job.printer_id, job.filename],
+ id=str(job.id),
+ )
+ return make_success({"job_id": job.id})
+ except Exception as e:
+ logger.error(f"Job creation failed: {e}")
+ return make_error("JOB_FAILED", str(e), 500)
+
+
+@api_bp.route("/jobs")
+@login_required
+def api_list_jobs():
+ history = request.args.get("history", "false").lower() == "true"
+ q = PrintJob.query
+ if not history:
+ q = q.filter(PrintJob.status == JobStatus.QUEUED)
+ jobs = q.order_by(PrintJob.scheduled.desc()).all()
+ return make_success([j.to_dict() for j in jobs])
+
+
+@api_bp.route('/printer//remove', methods=['POST'])
+@login_required
+def api_remove_printer(printer_id):
+ if printer_id not in printers:
+ return make_error("NOT_FOUND", "Printer not found", 404)
+ name = printers[printer_id].get("name", printer_id)
+ if not remove_printer(printer_id):
+ return make_error("REMOVE_FAILED", "Failed to remove printer", 500)
+
+ # Clean up in DB as well
+ try:
+ from app.models import Printer as DBPrinter
+ dbp = DBPrinter.query.get(printer_id)
+ if dbp:
+ db.session.delete(dbp)
+ db.session.commit()
+ except Exception as db_e:
+ logger.warning(f"DB cleanup failed: {db_e}")
+
+ return make_success({"message": f"Printer '{name}' removed"})
+
+
+@api_bp.route('/logs')
+@login_required
+def api_get_logs():
+ """
+ Return the last N lines of the application log.
+ Configuration key: LOG_FILE (must be set in app.config).
+ """
+ log_path = current_app.config.get('LOG_FILE', 'logs/chitui.log')
+ try:
+ lines = get_last_log_lines(log_path, num_lines=500)
+ except FileNotFoundError as e:
+ return make_error('NOT_FOUND', str(e), 404)
+
+ return make_success({'lines': lines})
+
+
+@api_bp.route("/health")
+def api_health():
+ start = current_app.config.get('_start_time', time.time())
+ uptime = round(time.time() - start)
+ return make_success({
+ "uptime": uptime,
+ "printers": len(printers),
+ })
+
+
+@api_bp.route('/printer//camera/status')
+@login_required
+def api_camera_status(printer_id):
+ p = printers.get(printer_id)
+ if not p:
+ return make_error('NOT_FOUND', "Printer not found", 404)
+ if 'camera_config' not in p:
+ return make_error('BAD_REQUEST', "Printer has no camera", 400)
+ return make_success({'enabled': p.get('camera_config', {}).get('enabled', False)})
\ No newline at end of file
diff --git a/app/auth.py b/app/auth.py
new file mode 100644
index 0000000..cc7ef5e
--- /dev/null
+++ b/app/auth.py
@@ -0,0 +1,188 @@
+"""
+Authentication module for ChitUI
+"""
+from flask import Blueprint, render_template, redirect, url_for, request, flash, current_app
+from flask_login import login_user, logout_user, login_required, current_user
+from loguru import logger
+import uuid
+from datetime import datetime
+from app.models import db, User
+
+# Create blueprint
+auth_bp = Blueprint('auth', __name__)
+
+
+@auth_bp.route('/login', methods=['GET', 'POST'])
+def login():
+ """Handle user login."""
+ # If user is already logged in, redirect to home
+ if current_user.is_authenticated:
+ return redirect(url_for('routes.index'))
+
+ error = None
+ if request.method == 'POST':
+ username = request.form.get('username')
+ password = request.form.get('password')
+ remember = 'remember' in request.form
+
+ # Find user by username
+ user = User.query.filter_by(username=username).first()
+
+ if user and user.check_password(password):
+ # Update last login time
+ user.last_login = datetime.utcnow()
+ db.session.commit()
+
+ login_user(user, remember=remember)
+ logger.info(f"User {username} logged in")
+
+ # Redirect to the page the user was trying to access
+ next_page = request.args.get('next')
+ if next_page:
+ return redirect(next_page)
+ return redirect(url_for('routes.index'))
+ else:
+ error = "Invalid username or password"
+ logger.warning(f"Failed login attempt for username: {username}")
+
+ return render_template('login.html', error=error)
+
+
+@auth_bp.route('/logout')
+@login_required
+def logout():
+ """Handle user logout."""
+ logger.info(f"User {current_user.username} logged out")
+ logout_user()
+ return redirect(url_for('auth.login'))
+
+
+@auth_bp.route('/users', methods=['GET'])
+@login_required
+def list_users():
+ """List all users (admin only)."""
+ if not current_user.is_admin():
+ flash("You don't have permission to access this page.", "danger")
+ return redirect(url_for('routes.index'))
+
+ users = User.query.all()
+ return render_template('admin.html',
+ users=users,
+ current_user=current_user,
+ user=current_user)
+
+
+@auth_bp.route('/users/add', methods=['POST'])
+@login_required
+def add_user():
+ """Add a new user (admin only)."""
+ if not current_user.is_admin():
+ flash("You don't have permission to perform this action.", "danger")
+ return redirect(url_for('routes.index'))
+
+ username = request.form.get('username')
+ password = request.form.get('password')
+ role = request.form.get('role', 'user')
+
+ # Check if username already exists
+ existing_user = User.query.filter_by(username=username).first()
+ if existing_user:
+ flash(f"Username '{username}' already exists.", "danger")
+ return redirect(url_for('auth.list_users'))
+
+ # Create user
+ user = User(
+ id=str(uuid.uuid4()),
+ username=username,
+ password=password,
+ role=role
+ )
+
+ db.session.add(user)
+ db.session.commit()
+
+ logger.info(f"User '{username}' created by admin: {current_user.username}")
+ flash(f"User '{username}' created successfully.", "success")
+ return redirect(url_for('auth.list_users'))
+
+
+@auth_bp.route('/users//delete', methods=['POST'])
+@login_required
+def delete_user(user_id):
+ """Delete a user (admin only)."""
+ if not current_user.is_admin():
+ flash("You don't have permission to perform this action.", "danger")
+ return redirect(url_for('routes.index'))
+
+ # Prevent deleting self
+ if user_id == current_user.id:
+ flash("You cannot delete your own account.", "danger")
+ return redirect(url_for('auth.list_users'))
+
+ # Delete user
+ user = User.query.get(user_id)
+ if user:
+ username = user.username
+ db.session.delete(user)
+ db.session.commit()
+ logger.info(
+ f"User '{username}' deleted by admin: {current_user.username}")
+ flash(f"User '{username}' deleted successfully.", "success")
+ else:
+ flash("User not found.", "danger")
+
+ return redirect(url_for('auth.list_users'))
+
+
+@auth_bp.route('/users//reset-password', methods=['POST'])
+@login_required
+def reset_password(user_id):
+ """Reset a user's password (admin only or own account)."""
+ if not current_user.is_admin() and user_id != current_user.id:
+ flash("You don't have permission to perform this action.", "danger")
+ return redirect(url_for('routes.index'))
+
+ password = request.form.get('password')
+
+ user = User.query.get(user_id)
+ if user:
+ user.set_password(password)
+ db.session.commit()
+ logger.info(
+ f"Password reset for user '{user.username}' by: {current_user.username}")
+ flash("Password updated successfully.", "success")
+ else:
+ flash("User not found.", "danger")
+
+ if current_user.is_admin():
+ return redirect(url_for('auth.list_users'))
+ else:
+ return redirect(url_for('routes.index'))
+
+
+@auth_bp.route('/account', methods=['GET', 'POST'])
+@login_required
+def account():
+ """User account management."""
+ if request.method == 'POST':
+ current_password = request.form.get('current_password')
+ new_password = request.form.get('new_password')
+ confirm_password = request.form.get('confirm_password')
+
+ # Validate input
+ if not current_password or not new_password or not confirm_password:
+ flash("All fields are required.", "danger")
+ elif not current_user.check_password(current_password):
+ flash("Current password is incorrect.", "danger")
+ elif new_password != confirm_password:
+ flash("New passwords do not match.", "danger")
+ else:
+ # Update password
+ current_user.set_password(new_password)
+ db.session.commit()
+ logger.info(
+ f"User '{current_user.username}' changed their password")
+ flash("Password updated successfully.", "success")
+ return redirect(url_for('routes.index'))
+
+ return render_template('account.html', user=current_user)
diff --git a/app/constants.py b/app/constants.py
new file mode 100644
index 0000000..e461a07
--- /dev/null
+++ b/app/constants.py
@@ -0,0 +1,138 @@
+"""
+Constants used throughout the ChitUI application.
+"""
+
+# Allowed file extensions for upload
+ALLOWED_EXTENSIONS = {"ctb", "goo", "prz"}
+
+# Machine statuses
+MACHINE_STATUS = {
+ 0: {"name": "IDLE", "description": "Idle"},
+ 1: {"name": "PRINTING", "description": "Executing print task"},
+ 2: {"name": "PAUSED", "description": "Suspended"},
+ 3: {"name": "STOPPED", "description": "Stopped"},
+ 4: {"name": "HOMING", "description": "Resetting"},
+ 5: {"name": "DROPPING", "description": "Descending"},
+ 6: {"name": "LIFTING", "description": "Lifting"},
+ 7: {"name": "EXPOSING", "description": "Exposing"},
+ 8: {"name": "FILE_TRANSFER", "description": "File transfer in progress"},
+ 9: {"name": "EXPOSURE_TEST", "description": "Exposure test in progress"},
+ 10: {"name": "DEVICE_CHECK", "description": "Device selfβcheck in progress"},
+ 11: {"name": "UNKNOWN", "description": "Unknown / reserved"},
+}
+
+# Print statuses
+PRINT_STATUS = {
+ 0: {"name": "IDLE", "description": "Idle"},
+ 1: {"name": "HOMING", "description": "Resetting"},
+ 2: {"name": "DROPPING", "description": "Descending"},
+ 3: {"name": "EXPOSING", "description": "Exposing"},
+ 4: {"name": "LIFTING", "description": "Lifting"},
+ 5: {"name": "PAUSING", "description": "Executing pause action"},
+ 6: {"name": "PAUSED", "description": "Suspended"},
+ 7: {"name": "STOPPING", "description": "Executing stop action"},
+ 8: {"name": "STOPPED", "description": "Stopped"},
+ 9: {"name": "COMPLETE", "description": "Print complete"},
+ 10: {"name": "FILE_CHECK", "description": "File checking in progress"},
+}
+
+# Print errors
+PRINT_ERROR = {
+ 0: {"name": "NONE", "description": "Normal"},
+ 1: {"name": "CHECK", "description": "File MD5 Check Failed"},
+ 2: {"name": "FILEIO", "description": "File Read Failed"},
+ 3: {"name": "INVLAID_RESOLUTION", "description": "Resolution Mismatch"},
+ 4: {"name": "UNKNOWN_FORMAT", "description": "Format Mismatch"},
+ 5: {"name": "UNKNOWN_MODEL", "description": "Machine Model Mismatch"},
+}
+
+# File transfer
+FILE_TRANSFER = {
+ 0: {"name": "ACK_SUCCESS", "description": "Success"},
+ 1: {
+ "name": "ACK_NOT_TRANSFER",
+ "description": "The printer is not currently transferring files.",
+ },
+ 2: {
+ "name": "ACK_CHECKING",
+ "description": "The printer is already in the file verification phase.",
+ },
+ 3: {"name": "ACK_NOT_FOUND", "description": "File not found."},
+}
+
+# Print control
+PRINT_CTRL = {
+ 0: {"name": "ACK_OK", "description": "OK"},
+ 1: {"name": "ACK_BUSY", "description": "Busy"},
+ 2: {"name": "ACK_NOT_FOUND", "description": "File Not Found"},
+ 3: {"name": "ACK_MD5_FAILED", "description": "MD5 Verification Failed"},
+ 4: {"name": "ACK_FILEIO_FAILED", "description": "File Read Failed"},
+ 5: {"name": "ACK_INVLAID_RESOLUTION", "description": "Resolution Mismatch"},
+ 6: {"name": "ACK_UNKNOW_FORMAT", "description": "Unrecognized File Format"},
+ 7: {"name": "ACK_UNKNOW_MODEL", "description": "Machine Model Mismatch"},
+}
+
+# Commands
+CMD = {
+ "STATUS": 0,
+ "ATTRIBUTES": 1,
+ "START_PRINT": 128,
+ "PAUSE_PRINT": 129,
+ "STOP_PRINT": 130,
+ "RESUME_PRINT": 131,
+ "STOP_FEEDING": 132,
+ "SKIP_PREHEATING": 133,
+ "CHANGE_PRINTER_NAME": 192,
+ "TERMINATE_FILE_TRANSFER": 255,
+ "FILE_LIST": 258,
+ "BATCH_DELETE_FILES": 259,
+ "DELETE_FILE": 259,
+ "TASK_DETAILS": 321,
+ "VIDEO_STREAM": 386,
+ "TIMELAPSE": 387,
+}
+
+# Printer models that support camera streaming
+CAMERA_ENABLED_MODELS = {
+ "saturnultra16k": {
+ "snapshot": "http://{ip}:8899/snapshot",
+ "mjpeg": "http://{ip}:8899/stream",
+ "resolution": "1280x720",
+ "fps": 15,
+ },
+ # Elegooβ―Saturnβ―Ultraβ―14β―K β same camera API but different model string
+ "saturnultra14k": {
+ "snapshot": "http://{ip}:8899/snapshot",
+ "mjpeg": "http://{ip}:8899/stream",
+ "resolution": "1280x720",
+ "fps": 15,
+ },
+ "saturn4ultra": {
+ "snapshot": "http://{ip}:8899/snapshot",
+ "mjpeg": "http://{ip}:8899/stream",
+ "resolution": "1280x720",
+ "fps": 15,
+ },
+ "saturn4": {
+ "snapshot": "http://{ip}:8899/snapshot",
+ "mjpeg": "http://{ip}:8899/stream",
+ "resolution": "1280x720",
+ "fps": 15,
+ },
+ "saturn3ultra": {
+ "snapshot": "http://{ip}:8899/snapshot",
+ "mjpeg": "http://{ip}:8899/stream",
+ "resolution": "1280x720",
+ "fps": 15,
+ },
+}
+
+# Printer icons mapping
+PRINTER_ICONS = {
+ "elegoo_saturn4ultra": "/static/img/elegoo_saturn4ultra.webp",
+ "elegoo_saturn4": "/static/img/elegoo_saturn4ultra.webp",
+ "elegoo_saturn3ultra": "/static/img/elegoo_saturn4ultra.webp",
+ "elegoo_saturnultra16k": "/static/img/elegoo_saturn4ultra.webp",
+ "elegoo_saturnultra14k": "/static/img/elegoo_saturn4ultra.webp",
+ "default": "/static/img/default_printer.png",
+}
diff --git a/app/database/__init__.py b/app/database/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/database/db_backup.py b/app/database/db_backup.py
new file mode 100644
index 0000000..d249fa2
--- /dev/null
+++ b/app/database/db_backup.py
@@ -0,0 +1,330 @@
+# db_backup.py
+import os
+import sys
+import time
+import gzip
+import shutil
+import sqlite3
+import subprocess
+from datetime import datetime
+from pathlib import Path
+from loguru import logger
+import json
+
+class DatabaseBackup:
+ def __init__(self, config):
+ """Initialize backup manager with configuration."""
+ self.config = config
+ self.backup_dir = Path(config.get('backup_folder', 'backups'))
+ self.backup_dir.mkdir(exist_ok=True)
+
+ # Database URI
+ self.db_uri = config.get('database_uri', 'sqlite:///chitui.db')
+
+ # Metadata storage
+ self.metadata_file = self.backup_dir / "backup_metadata.json"
+ self.metadata = self._load_metadata()
+
+ def _load_metadata(self):
+ """Load backup metadata from file."""
+ if not self.metadata_file.exists():
+ return {"backups": []}
+
+ try:
+ with open(self.metadata_file, 'r') as f:
+ return json.load(f)
+ except Exception as e:
+ logger.error(f"Failed to load backup metadata: {e}")
+ return {"backups": []}
+
+ def _save_metadata(self):
+ """Save backup metadata to file."""
+ try:
+ with open(self.metadata_file, 'w') as f:
+ json.dump(self.metadata, f, indent=2)
+ except Exception as e:
+ logger.error(f"Failed to save backup metadata: {e}")
+
+ def _get_db_type(self):
+ """Get database type from URI."""
+ if self.db_uri.startswith('sqlite:'):
+ return 'sqlite'
+ elif self.db_uri.startswith('mysql:') or self.db_uri.startswith('mysql+pymysql:'):
+ return 'mysql'
+ elif self.db_uri.startswith('postgresql:'):
+ return 'postgresql'
+ else:
+ return 'unknown'
+
+ def _get_db_path(self):
+ """Get database file path for SQLite."""
+ if self._get_db_type() == 'sqlite':
+ if self.db_uri.startswith('sqlite:///'):
+ # Relative path
+ return self.db_uri[10:]
+ elif self.db_uri.startswith('sqlite://'):
+ # Absolute path
+ return self.db_uri[9:]
+ return None
+
+ def create_backup(self, description=None):
+ """Create a new database backup."""
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ backup_id = f"{timestamp}_{os.urandom(4).hex()}"
+ backup_filename = f"chitui_backup_{timestamp}.gz"
+ backup_path = self.backup_dir / backup_filename
+
+ db_type = self._get_db_type()
+ success = False
+ error_message = None
+
+ try:
+ if db_type == 'sqlite':
+ # SQLite backup - file copy with compression
+ db_path = self._get_db_path()
+ if not db_path or not Path(db_path).exists():
+ raise FileNotFoundError(f"Database file not found: {db_path}")
+
+ with open(db_path, 'rb') as f_in:
+ with gzip.open(backup_path, 'wb') as f_out:
+ shutil.copyfileobj(f_in, f_out)
+
+ success = True
+
+ elif db_type == 'mysql':
+ # MySQL backup using mysqldump
+ from sqlalchemy.engine.url import make_url
+ url = make_url(self.db_uri)
+
+ cmd = [
+ 'mysqldump',
+ f'--host={url.host}',
+ f'--port={url.port or 3306}',
+ f'--user={url.username}',
+ ]
+
+ if url.password:
+ cmd.append(f'--password={url.password}')
+
+ cmd.append(url.database)
+
+ with gzip.open(backup_path, 'wb') as f_out:
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+ for chunk in iter(lambda: process.stdout.read(4096), b''):
+ f_out.write(chunk)
+
+ if process.wait() == 0:
+ success = True
+ else:
+ error_message = "mysqldump command failed"
+
+ elif db_type == 'postgresql':
+ # PostgreSQL backup using pg_dump
+ from sqlalchemy.engine.url import make_url
+ url = make_url(self.db_uri)
+
+ env = os.environ.copy()
+ if url.password:
+ env['PGPASSWORD'] = url.password
+
+ cmd = [
+ 'pg_dump',
+ f'--host={url.host}',
+ f'--port={url.port or 5432}',
+ f'--username={url.username}',
+ f'--dbname={url.database}',
+ '--format=c', # Custom format (compressed)
+ ]
+
+ with open(backup_path, 'wb') as f_out:
+ process = subprocess.Popen(cmd, stdout=f_out, env=env)
+ process.wait()
+
+ if process.returncode == 0:
+ success = True
+ else:
+ error_message = "pg_dump command failed"
+
+ else:
+ error_message = f"Unsupported database type: {db_type}"
+
+ except Exception as e:
+ error_message = str(e)
+ logger.error(f"Backup failed: {e}")
+
+ # Clean up if file was created
+ if backup_path.exists():
+ backup_path.unlink()
+
+ # Add to metadata
+ backup_info = {
+ "id": backup_id,
+ "filename": backup_filename,
+ "timestamp": timestamp,
+ "db_type": db_type,
+ "description": description,
+ "success": success,
+ "error": error_message,
+ "size": backup_path.stat().st_size if backup_path.exists() else 0,
+ }
+
+ self.metadata["backups"].append(backup_info)
+ self._save_metadata()
+
+ if success:
+ logger.info(f"Backup created: {backup_filename}")
+ return backup_info
+ else:
+ logger.error(f"Backup failed: {error_message}")
+ return None
+
+ def restore_backup(self, backup_id):
+ """Restore database from backup."""
+ # Find backup in metadata
+ backup_info = None
+ for backup in self.metadata["backups"]:
+ if backup["id"] == backup_id:
+ backup_info = backup
+ break
+
+ if not backup_info:
+ logger.error(f"Backup not found: {backup_id}")
+ return False
+
+ backup_path = self.backup_dir / backup_info["filename"]
+ if not backup_path.exists():
+ logger.error(f"Backup file not found: {backup_path}")
+ return False
+
+ db_type = self._get_db_type()
+ success = False
+ error_message = None
+
+ try:
+ if db_type == 'sqlite':
+ # SQLite restore - file copy
+ db_path = self._get_db_path()
+ if not db_path:
+ raise ValueError("Database path not found in URI")
+
+ # Create backup of current database
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ current_backup = f"{db_path}.{timestamp}.bak"
+ shutil.copy2(db_path, current_backup)
+
+ # Restore from backup
+ with gzip.open(backup_path, 'rb') as f_in:
+ with open(db_path, 'wb') as f_out:
+ shutil.copyfileobj(f_in, f_out)
+
+ success = True
+
+ elif db_type == 'mysql':
+ # MySQL restore
+ from sqlalchemy.engine.url import make_url
+ url = make_url(self.db_uri)
+
+ cmd = [
+ 'mysql',
+ f'--host={url.host}',
+ f'--port={url.port or 3306}',
+ f'--user={url.username}',
+ ]
+
+ if url.password:
+ cmd.append(f'--password={url.password}')
+
+ cmd.append(url.database)
+
+ with gzip.open(backup_path, 'rb') as f_in:
+ process = subprocess.Popen(cmd, stdin=subprocess.PIPE)
+ for chunk in iter(lambda: f_in.read(4096), b''):
+ process.stdin.write(chunk)
+ process.stdin.close()
+
+ if process.wait() == 0:
+ success = True
+ else:
+ error_message = "mysql restore command failed"
+
+ elif db_type == 'postgresql':
+ # PostgreSQL restore
+ from sqlalchemy.engine.url import make_url
+ url = make_url(self.db_uri)
+
+ env = os.environ.copy()
+ if url.password:
+ env['PGPASSWORD'] = url.password
+
+ cmd = [
+ 'pg_restore',
+ f'--host={url.host}',
+ f'--port={url.port or 5432}',
+ f'--username={url.username}',
+ f'--dbname={url.database}',
+ '--clean', # Clean (drop) database objects before recreating
+ backup_path
+ ]
+
+ process = subprocess.Popen(cmd, env=env)
+ if process.wait() == 0:
+ success = True
+ else:
+ error_message = "pg_restore command failed"
+
+ else:
+ error_message = f"Unsupported database type: {db_type}"
+
+ except Exception as e:
+ error_message = str(e)
+ logger.error(f"Restore failed: {e}")
+
+ if success:
+ logger.info(f"Database restored from backup: {backup_info['filename']}")
+ return True
+ else:
+ logger.error(f"Restore failed: {error_message}")
+ return False
+
+ def list_backups(self, limit=None):
+ """List all backups."""
+ backups = sorted(
+ self.metadata["backups"],
+ key=lambda x: x["timestamp"],
+ reverse=True
+ )
+
+ if limit:
+ return backups[:limit]
+ return backups
+
+ def delete_backup(self, backup_id):
+ """Delete a backup."""
+ # Find backup in metadata
+ backup_info = None
+ backup_index = -1
+
+ for i, backup in enumerate(self.metadata["backups"]):
+ if backup["id"] == backup_id:
+ backup_info = backup
+ backup_index = i
+ break
+
+ if not backup_info:
+ logger.error(f"Backup not found: {backup_id}")
+ return False
+
+ backup_path = self.backup_dir / backup_info["filename"]
+ if backup_path.exists():
+ try:
+ backup_path.unlink()
+ except Exception as e:
+ logger.error(f"Failed to delete backup file: {e}")
+ return False
+
+ # Remove from metadata
+ self.metadata["backups"].pop(backup_index)
+ self._save_metadata()
+
+ logger.info(f"Backup deleted: {backup_info['filename']}")
+ return True
\ No newline at end of file
diff --git a/app/database/db_cli.py b/app/database/db_cli.py
new file mode 100644
index 0000000..fce286f
--- /dev/null
+++ b/app/database/db_cli.py
@@ -0,0 +1,254 @@
+# db_cli.py
+import click
+import typer
+from rich.console import Console
+from rich.table import Table
+from pathlib import Path
+from datetime import datetime
+import time
+
+from app.database.db_config import setup_db_engine
+from app.database.db_migration import (
+ create_migration, run_migrations, downgrade_migrations,
+ list_migrations, get_current_version
+)
+from app.database.db_backup import DatabaseBackup
+from app.utils import load_config_file as load_config
+
+# Create console for rich output
+console = Console()
+
+# Create CLI app
+app = typer.Typer(name="chituidb", help="ChitUI Database Management CLI")
+
+@app.command()
+def info(
+ config_file: str = typer.Option(None, "--config", "-c", help="Path to configuration file")
+):
+ """Display database information."""
+ from app.utils import load_config
+
+ # Load configuration
+ config = load_config(config_file)
+
+ # Get engine
+ engine, _ = setup_db_engine(config)
+
+ # Display information
+ table = Table(title="Database Information")
+
+ table.add_column("Property", style="cyan")
+ table.add_column("Value", style="green")
+
+ table.add_row("Database URI", config.get('database_uri', 'Not set'))
+ table.add_row("Driver", engine.driver)
+ table.add_row("Dialect", engine.dialect.name)
+
+ if engine.dialect.name == 'sqlite':
+ db_path = config.get('database_uri', '').replace('sqlite:///', '')
+ if db_path:
+ path = Path(db_path)
+ if path.exists():
+ size_mb = path.stat().st_size / (1024 * 1024)
+ table.add_row("Database Size", f"{size_mb:.2f} MB")
+ table.add_row("Last Modified", datetime.fromtimestamp(path.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S'))
+
+ # Get current version
+ current_version = get_current_version(engine)
+ table.add_row("Current Version", current_version)
+
+ # Get connection pool info
+ table.add_row("Pool Size", str(engine.pool.size()))
+ table.add_row("Pool Timeout", str(engine.pool.timeout()))
+
+ console.print(table)
+
+@app.command()
+def migrate(
+ message: str = typer.Argument(..., help="Migration message"),
+ config_file: str = typer.Option(None, "--config", "-c", help="Path to configuration file")
+):
+ """Create a new migration."""
+ if create_migration(message):
+ console.print(f"[green]Migration created: {message}[/green]")
+ else:
+ console.print("[red]Failed to create migration[/red]")
+
+@app.command()
+def upgrade(
+ config_file: str = typer.Option(None, "--config", "-c", help="Path to configuration file")
+):
+ """Run all pending migrations."""
+ with console.status("[bold green]Running migrations...[/bold green]") as status:
+ if run_migrations():
+ console.print("[green]Migrations completed successfully[/green]")
+ else:
+ console.print("[red]Failed to run migrations[/red]")
+
+@app.command()
+def downgrade(
+ revision: str = typer.Argument(..., help="Revision to downgrade to"),
+ config_file: str = typer.Option(None, "--config", "-c", help="Path to configuration file")
+):
+ """Downgrade migrations to a specific revision."""
+ with console.status(f"[bold yellow]Downgrading to revision: {revision}[/bold yellow]") as status:
+ if downgrade_migrations(revision):
+ console.print(f"[green]Downgraded to revision: {revision}[/green]")
+ else:
+ console.print("[red]Failed to downgrade migrations[/red]")
+
+@app.command()
+def backup(
+ description: str = typer.Option(None, "--desc", "-d", help="Backup description"),
+ config_file: str = typer.Option(None, "--config", "-c", help="Path to configuration file")
+):
+ """Create a database backup."""
+ from app.utils import load_config
+
+ # Load configuration
+ config = load_config(config_file)
+
+ backup_manager = DatabaseBackup(config)
+
+ with console.status("[bold green]Creating backup...[/bold green]") as status:
+ backup_info = backup_manager.create_backup(description)
+
+ if backup_info:
+ console.print(f"[green]Backup created: {backup_info['filename']}[/green]")
+
+ # Print details
+ table = Table(title="Backup Information")
+ table.add_column("Property", style="cyan")
+ table.add_column("Value", style="green")
+
+ table.add_row("ID", backup_info["id"])
+ table.add_row("Filename", backup_info["filename"])
+ table.add_row("Timestamp", backup_info["timestamp"])
+ table.add_row("Size", f"{backup_info['size'] / (1024 * 1024):.2f} MB")
+
+ if backup_info["description"]:
+ table.add_row("Description", backup_info["description"])
+
+ console.print(table)
+ else:
+ console.print("[red]Backup failed[/red]")
+
+@app.command()
+def backups(
+ limit: int = typer.Option(10, "--limit", "-l", help="Maximum number of backups to list"),
+ config_file: str = typer.Option(None, "--config", "-c", help="Path to configuration file")
+):
+ """List database backups."""
+ from app.utils import load_config
+
+ # Load configuration
+ config = load_config(config_file)
+
+ backup_manager = DatabaseBackup(config)
+ backups = backup_manager.list_backups(limit)
+
+ if not backups:
+ console.print("[yellow]No backups found[/yellow]")
+ return
+
+ table = Table(title=f"Database Backups (showing {len(backups)} of {len(backup_manager.metadata['backups'])})")
+
+ table.add_column("ID", style="cyan")
+ table.add_column("Timestamp", style="green")
+ table.add_column("Size (MB)", justify="right")
+ table.add_column("Status", style="cyan")
+ table.add_column("Description")
+
+ for backup in backups:
+ size_mb = f"{backup['size'] / (1024 * 1024):.2f}"
+ status = "[green]Success[/green]" if backup["success"] else f"[red]Failed: {backup['error']}[/red]"
+ description = backup["description"] or ""
+
+ table.add_row(
+ backup["id"],
+ backup["timestamp"],
+ size_mb,
+ status,
+ description
+ )
+
+ console.print(table)
+
+@app.command()
+def restore(
+ backup_id: str = typer.Argument(..., help="Backup ID to restore"),
+ config_file: str = typer.Option(None, "--config", "-c", help="Path to configuration file")
+):
+ """Restore database from backup."""
+ from app.utils import load_config
+
+ # Load configuration
+ config = load_config(config_file)
+
+ backup_manager = DatabaseBackup(config)
+
+ # Get backup info
+ backup_info = None
+ for backup in backup_manager.metadata["backups"]:
+ if backup["id"] == backup_id:
+ backup_info = backup
+ break
+
+ if not backup_info:
+ console.print(f"[red]Backup not found: {backup_id}[/red]")
+ return
+
+ # Confirm
+ if not typer.confirm(f"Are you sure you want to restore from backup: {backup_info['filename']}?"):
+ console.print("[yellow]Restore cancelled[/yellow]")
+ return
+
+ with console.status("[bold green]Restoring database...[/bold green]") as status:
+ if backup_manager.restore_backup(backup_id):
+ console.print(f"[green]Database restored from backup: {backup_info['filename']}[/green]")
+ else:
+ console.print("[red]Restore failed[/red]")
+
+@app.command()
+def optimize(
+ config_file: str = typer.Option(None, "--config", "-c", help="Path to configuration file")
+):
+ """Optimize the database."""
+ from app.utils import load_config
+
+ # Load configuration
+ config = load_config(config_file)
+
+ # Get engine
+ engine, _ = setup_db_engine(config)
+
+ with console.status("[bold green]Optimizing database...[/bold green]") as status:
+ try:
+ if engine.dialect.name == 'sqlite':
+ # Execute VACUUM for SQLite
+ with engine.connect() as conn:
+ conn.execute("VACUUM;")
+ conn.execute("ANALYZE;")
+ console.print("[green]Database optimized successfully[/green]")
+ elif engine.dialect.name == 'mysql':
+ # Optimize tables for MySQL
+ with engine.connect() as conn:
+ result = conn.execute("SHOW TABLES;")
+ tables = [row[0] for row in result]
+
+ for table in tables:
+ conn.execute(f"OPTIMIZE TABLE {table};")
+ console.print("[green]Database tables optimized successfully[/green]")
+ elif engine.dialect.name == 'postgresql':
+ # VACUUM for PostgreSQL
+ with engine.connect() as conn:
+ conn.execute("VACUUM FULL;")
+ conn.execute("ANALYZE;")
+ console.print("[green]Database optimized successfully[/green]")
+ else:
+ console.print(f"[yellow]Optimization not supported for {engine.dialect.name}[/yellow]")
+ except Exception as e:
+ console.print(f"[red]Optimization failed: {e}[/red]")
+
+if __name__ == "__main__":
+ app()
\ No newline at end of file
diff --git a/app/database/db_config.py b/app/database/db_config.py
new file mode 100644
index 0000000..4811b5c
--- /dev/null
+++ b/app/database/db_config.py
@@ -0,0 +1,67 @@
+# db_config.py
+from sqlalchemy import create_engine, event
+from sqlalchemy.orm import sessionmaker, scoped_session
+from sqlalchemy.pool import QueuePool
+from sqlalchemy.exc import SQLAlchemyError
+from loguru import logger
+import time
+import os
+
+def get_database_url(config):
+ """Get database URL from config or environment."""
+ return config.get('database_uri', os.environ.get('DATABASE_URI', 'sqlite:///chitui.db'))
+
+def setup_db_engine(config, pool_size=10, max_overflow=20, timeout=30):
+ """Set up database engine with connection pooling."""
+ db_url = get_database_url(config)
+
+ connect_args = {}
+ if db_url.startswith('sqlite:'):
+ # SQLite-specific configuration
+ connect_args = {'check_same_thread': False}
+
+ engine = create_engine(
+ db_url,
+ poolclass=QueuePool,
+ pool_size=pool_size,
+ max_overflow=max_overflow,
+ pool_timeout=timeout,
+ pool_pre_ping=True, # Verify connections before using them
+ connect_args=connect_args
+ )
+
+ # Set up connection event listeners
+ @event.listens_for(engine, 'connect')
+ def on_connect(dbapi_connection, connection_record):
+ logger.debug("Database connection established")
+
+ @event.listens_for(engine, 'checkout')
+ def on_checkout(dbapi_connection, connection_record, connection_proxy):
+ connection_record.info['checkout_time'] = time.time()
+
+ @event.listens_for(engine, 'checkin')
+ def on_checkin(dbapi_connection, connection_record):
+ checkout_time = connection_record.info.get('checkout_time')
+ if checkout_time is not None:
+ connection_record.info.pop('checkout_time')
+ elapsed = time.time() - checkout_time
+ if elapsed > 10:
+ logger.warning(f"Connection held for {elapsed:.2f}s")
+
+ # Create session factory
+ session_factory = sessionmaker(bind=engine)
+ Session = scoped_session(session_factory)
+
+ return engine, Session
+
+def get_db_session(config):
+ """Get a database session."""
+ _, Session = setup_db_engine(config)
+ return Session()
+
+def close_db_session(session):
+ """Close a database session."""
+ try:
+ session.close()
+ except SQLAlchemyError as e:
+ logger.error(f"Error closing database session: {e}")
\ No newline at end of file
diff --git a/app/database/db_migration.py b/app/database/db_migration.py
new file mode 100644
index 0000000..a54ee2e
--- /dev/null
+++ b/app/database/db_migration.py
@@ -0,0 +1,83 @@
+# db_migration.py
+import os
+import sys
+import uuid
+import shutil
+from datetime import datetime
+from pathlib import Path
+from loguru import logger
+from alembic import command
+from alembic.config import Config as AlembicConfig
+from sqlalchemy import inspect
+
+def get_alembic_config(config_path='migrations/alembic.ini'):
+ """Get Alembic configuration."""
+ alembic_cfg = AlembicConfig(config_path)
+ # Set script location
+ alembic_cfg.set_main_option("script_location", "migrations")
+ return alembic_cfg
+
+def create_migration(message, config_path='migrations/alembic.ini'):
+ """Create a new migration."""
+ try:
+ alembic_cfg = get_alembic_config(config_path)
+ command.revision(alembic_cfg, message=message, autogenerate=True)
+ logger.info(f"Created migration: {message}")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to create migration: {e}")
+ return False
+
+def run_migrations(config_path='migrations/alembic.ini'):
+ """Run all pending migrations."""
+ try:
+ alembic_cfg = get_alembic_config(config_path)
+ command.upgrade(alembic_cfg, "head")
+ logger.info("Migrations completed successfully")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to run migrations: {e}")
+ return False
+
+def downgrade_migrations(revision, config_path='migrations/alembic.ini'):
+ """Downgrade migrations to a specific revision."""
+ try:
+ alembic_cfg = get_alembic_config(config_path)
+ command.downgrade(alembic_cfg, revision)
+ logger.info(f"Downgraded to revision: {revision}")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to downgrade migrations: {e}")
+ return False
+
+def list_migrations(config_path='migrations/alembic.ini'):
+ """List all migrations and their status."""
+ try:
+ alembic_cfg = get_alembic_config(config_path)
+ command.history(alembic_cfg, verbose=True)
+ return True
+ except Exception as e:
+ logger.error(f"Failed to list migrations: {e}")
+ return False
+
+def get_current_version(engine):
+ """Get current database version."""
+ try:
+ inspector = inspect(engine)
+ has_alembic_table = inspector.has_table('alembic_version')
+
+ if not has_alembic_table:
+ return "No migrations applied"
+
+ from alembic.migration import MigrationContext
+ from alembic.operations import Operations
+
+ conn = engine.connect()
+ context = MigrationContext.configure(conn)
+ current_rev = context.get_current_revision()
+ conn.close()
+
+ return current_rev or "No migrations applied"
+ except Exception as e:
+ logger.error(f"Failed to get current database version: {e}")
+ return "Error getting version"
\ No newline at end of file
diff --git a/app/database/db_utils.py b/app/database/db_utils.py
new file mode 100644
index 0000000..9a00157
--- /dev/null
+++ b/app/database/db_utils.py
@@ -0,0 +1,285 @@
+"""
+Database utility functions for ChitUI
+"""
+from app.models import db, User, Printer, PrintJob, SystemSetting
+from loguru import logger
+import uuid
+from datetime import datetime
+import json
+import os
+from flask import current_app
+
+
+def sync_printers_to_db(runtime_printers):
+ """
+ Sync runtime printers dictionary to database.
+
+ Args:
+ runtime_printers (dict): Dictionary of printers discovered at runtime
+ """
+ # Import here to avoid circular imports
+ from app import printers
+
+ for printer_id, printer_data in runtime_printers.items():
+ # Check if printer exists in database
+ db_printer = Printer.query.get(printer_id)
+
+ if not db_printer:
+ # Create new printer record
+ db_printer = Printer(
+ id=printer_id,
+ name=printer_data.get('name', f"Printer {printer_id[:8]}"),
+ ip_address=printer_data.get('ip', '0.0.0.0'),
+ model=printer_data.get('model', 'unknown'),
+ brand=printer_data.get('brand', 'unknown'),
+ connection_id=printer_data.get('connection', ''),
+ firmware=printer_data.get('firmware', ''),
+ protocol=printer_data.get('protocol', ''),
+ last_seen=datetime.utcnow(),
+ user_id=current_app.config.get('CHITUI_CONFIG', {}).get('default_user_id', None)
+ )
+ db.session.add(db_printer)
+ logger.info(f"Added new printer to database: {printer_data.get('name')}")
+ else:
+ # Update existing printer
+ db_printer.name = printer_data.get('name', db_printer.name)
+ db_printer.ip_address = printer_data.get('ip', db_printer.ip_address)
+ db_printer.model = printer_data.get('model', db_printer.model)
+ db_printer.brand = printer_data.get('brand', db_printer.brand)
+ db_printer.connection_id = printer_data.get('connection', db_printer.connection_id)
+ db_printer.firmware = printer_data.get('firmware', db_printer.firmware)
+ db_printer.protocol = printer_data.get('protocol', db_printer.protocol)
+ db_printer.last_seen = datetime.utcnow()
+
+ # Update settings if needed
+ settings = db_printer.get_settings() or {}
+
+ # Add camera settings if available
+ if printer_data.get('supports_camera', False):
+ settings['supports_camera'] = True
+ if 'camera_config' in printer_data:
+ settings['camera_config'] = printer_data['camera_config']
+
+ # Save settings
+ db_printer.set_settings(settings)
+
+ logger.debug(f"Updated printer in database: {db_printer.name}")
+
+ db.session.commit()
+
+
+def load_printers_from_db():
+ """
+ Load printers from database into runtime dictionary.
+
+ Returns:
+ dict: Dictionary of printers loaded from database
+ """
+ # Import here to avoid circular imports
+ from app import printers
+
+ db_printers = Printer.query.all()
+ loaded_printers = {}
+
+ for db_printer in db_printers:
+ settings = db_printer.get_settings() or {}
+
+ printer_data = {
+ 'id': db_printer.id,
+ 'name': db_printer.name,
+ 'ip': db_printer.ip_address,
+ 'model': db_printer.model,
+ 'brand': db_printer.brand,
+ 'connection': db_printer.connection_id,
+ 'firmware': db_printer.firmware,
+ 'protocol': db_printer.protocol,
+ 'status': 'disconnected',
+ 'last_seen': db_printer.last_seen.timestamp() if db_printer.last_seen else None,
+ 'machine_status': None,
+ 'print_status': None,
+ 'print_progress': None,
+ 'current_file': None,
+ 'remain_time': None,
+ 'files': [],
+ 'supports_camera': settings.get('supports_camera', False),
+ 'camera_config': settings.get('camera_config', {}),
+ 'from_database': True
+ }
+
+ # Set printer icon based on brand and model
+ from app.constants import PRINTER_ICONS
+ icon_key = f"{printer_data['brand']}_{printer_data['model']}".replace(" ", "")
+ printer_data['icon'] = PRINTER_ICONS.get(icon_key, PRINTER_ICONS['default'])
+
+ loaded_printers[db_printer.id] = printer_data
+ logger.debug(f"Loaded printer from database: {db_printer.name}")
+
+ return loaded_printers
+
+
+def create_print_job(printer_id, filename, user_id=None):
+ """
+ Create a new print job record.
+
+ Args:
+ printer_id (str): Printer ID
+ filename (str): File name
+ user_id (str, optional): User ID (defaults to admin)
+
+ Returns:
+ PrintJob: Created print job record
+ """
+ # Get user ID if not provided
+ if not user_id:
+ admin_user = User.query.filter_by(role='admin').first()
+ user_id = admin_user.id if admin_user else None
+
+ # Create print job
+ print_job = PrintJob(
+ id=str(uuid.uuid4()),
+ filename=filename,
+ start_time=datetime.utcnow(),
+ status='started',
+ printer_id=printer_id,
+ user_id=user_id
+ )
+
+ db.session.add(print_job)
+ db.session.commit()
+
+ logger.info(f"Created new print job: {filename} on printer {printer_id}")
+ return print_job
+
+
+def update_print_job(printer_id, status, progress=None, completed_layers=None, total_layers=None):
+ """
+ Update an active print job record.
+
+ Args:
+ printer_id (str): Printer ID
+ status (str): Job status (started, printing, completed, failed, cancelled)
+ progress (int, optional): Job progress percentage
+ completed_layers (int, optional): Number of completed layers
+ total_layers (int, optional): Total number of layers
+
+ Returns:
+ PrintJob: Updated print job record or None if not found
+ """
+ # Find active print job for printer
+ print_job = PrintJob.query.filter_by(printer_id=printer_id, end_time=None).order_by(PrintJob.start_time.desc()).first()
+
+ if not print_job:
+ logger.warning(f"No active print job found for printer {printer_id}")
+ return None
+
+ # Update print job
+ print_job.status = status
+
+ if status in ['completed', 'failed', 'cancelled']:
+ print_job.end_time = datetime.utcnow()
+ if print_job.start_time:
+ # Calculate duration in seconds
+ duration = (print_job.end_time - print_job.start_time).total_seconds()
+ print_job.duration = int(duration)
+
+ if total_layers is not None:
+ print_job.layers = total_layers
+
+ if completed_layers is not None:
+ print_job.completed_layers = completed_layers
+
+ db.session.commit()
+
+ logger.debug(f"Updated print job {print_job.id}: status={status}, progress={progress}")
+ return print_job
+
+
+def get_setting(key, default=None):
+ """
+ Get a system setting value.
+
+ Args:
+ key (str): Setting key
+ default: Default value if setting not found
+
+ Returns:
+ The setting value or default
+ """
+ setting = SystemSetting.query.get(key)
+
+ if setting:
+ return setting.get_value()
+ else:
+ return default
+
+
+def set_setting(key, value, type_name='string'):
+ """
+ Set a system setting value.
+
+ Args:
+ key (str): Setting key
+ value: Setting value
+ type_name (str): Value type (string, int, float, bool, json)
+ """
+ setting = SystemSetting.query.get(key)
+
+ if not setting:
+ setting = SystemSetting(key=key, type=type_name)
+ db.session.add(setting)
+
+ setting.set_value(value)
+ db.session.commit()
+
+
+def backup_database(backup_dir='backups'):
+ """
+ Create a backup of the database.
+
+ Args:
+ backup_dir (str): Directory to store backups
+
+ Returns:
+ str: Path to backup file or None if failed
+ """
+ try:
+ # Create backup directory if it doesn't exist
+ os.makedirs(backup_dir, exist_ok=True)
+
+ # Get database URI
+ db_uri = current_app.config['SQLALCHEMY_DATABASE_URI']
+
+ # SQLite backup is simple file copy
+ if db_uri.startswith('sqlite:///'):
+ import shutil
+ from sqlalchemy.engine.url import make_url
+
+ url = make_url(db_uri)
+ db_path = url.database
+
+ # Remove sqlite:/// prefix for filesystem path
+ if db_path.startswith('/'):
+ db_path = db_path[1:]
+
+ # Create backup filename with timestamp
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ backup_file = os.path.join(backup_dir, f'chitui_backup_{timestamp}.db')
+
+ # Copy database file
+ shutil.copy2(db_path, backup_file)
+
+ # Update last backup setting
+ set_setting('last_backup', datetime.utcnow().isoformat())
+
+ logger.info(f"Database backup created: {backup_file}")
+ return backup_file
+
+ else:
+ # For other databases, we'd need to use database-specific tools
+ # This is a simplified version that only works for SQLite
+ logger.warning(f"Backup not implemented for database type: {db_uri}")
+ return None
+
+ except Exception as e:
+ logger.error(f"Database backup failed: {e}")
+ return None
\ No newline at end of file
diff --git a/web/assets/favicon.ico b/app/favicon.ico
similarity index 100%
rename from web/assets/favicon.ico
rename to app/favicon.ico
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 0000000..00201cf
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,156 @@
+"""
+Database models for ChitUI
+"""
+from datetime import datetime
+from enum import Enum, auto
+import uuid
+from flask_sqlalchemy import SQLAlchemy
+from werkzeug.security import generate_password_hash, check_password_hash
+from flask_login import UserMixin
+
+# Create SQLAlchemy instance
+db = SQLAlchemy()
+
+class JobStatus(str, Enum):
+ """Print job status enum."""
+ QUEUED = "QUEUED"
+ RUNNING = "RUNNING"
+ COMPLETED = "COMPLETED"
+ FAILED = "FAILED"
+ CANCELLED = "CANCELLED"
+
+class User(db.Model, UserMixin):
+ """User model for authentication."""
+ id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ username = db.Column(db.String(64), unique=True, nullable=False)
+ password_hash = db.Column(db.String(256), nullable=False)
+ role = db.Column(db.String(20), default='user')
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+ last_login = db.Column(db.DateTime, nullable=True)
+
+ def is_admin(self):
+ """Check if user has admin role."""
+ return self.role == 'admin'
+
+ @property
+ def password(self):
+ """Password getter (raises error)."""
+ raise AttributeError('Password is not a readable attribute')
+
+ @password.setter
+ def password(self, password):
+ """Hash and store password."""
+ self.password_hash = generate_password_hash(password)
+
+ def check_password(self, password):
+ """Check password against stored hash."""
+ return check_password_hash(self.password_hash, password)
+
+ def to_dict(self):
+ """Convert user to dictionary."""
+ return {
+ 'id': self.id,
+ 'username': self.username,
+ 'role': self.role,
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
+ 'last_login': self.last_login.isoformat() if self.last_login else None
+ }
+
+class Printer(db.Model):
+ """Printer model for database storage."""
+ id = db.Column(db.String(36), primary_key=True)
+ name = db.Column(db.String(64), nullable=False)
+ ip = db.Column(db.String(15), nullable=False)
+ model = db.Column(db.String(64))
+ brand = db.Column(db.String(64))
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+ last_seen = db.Column(db.DateTime, nullable=True)
+
+ def to_dict(self):
+ """Convert printer to dictionary."""
+ return {
+ 'id': self.id,
+ 'name': self.name,
+ 'ip': self.ip,
+ 'model': self.model,
+ 'brand': self.brand,
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
+ 'last_seen': self.last_seen.isoformat() if self.last_seen else None
+ }
+
+class PrintJob(db.Model):
+ """Print job model for database storage."""
+ id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ printer_id = db.Column(db.String(36), db.ForeignKey('printer.id'), nullable=False)
+ user_id = db.Column(db.String(36), db.ForeignKey('user.id'), nullable=True)
+ file_name = db.Column(db.String(256), nullable=False)
+ status = db.Column(db.String(20), default=JobStatus.QUEUED)
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+ scheduled = db.Column(db.DateTime, nullable=True)
+ started_at = db.Column(db.DateTime, nullable=True)
+ completed_at = db.Column(db.DateTime, nullable=True)
+
+ # Relationships
+ printer = db.relationship('Printer', backref=db.backref('jobs', lazy=True))
+ user = db.relationship('User', backref=db.backref('jobs', lazy=True))
+
+ def to_dict(self):
+ """Convert print job to dictionary."""
+ return {
+ 'id': self.id,
+ 'printer_id': self.printer_id,
+ 'user_id': self.user_id,
+ 'file_name': self.file_name,
+ 'status': self.status,
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
+ 'scheduled': self.scheduled.isoformat() if self.scheduled else None,
+ 'started_at': self.started_at.isoformat() if self.started_at else None,
+ 'completed_at': self.completed_at.isoformat() if self.completed_at else None
+ }
+
+class SystemSetting(db.Model):
+ """System settings model for database storage."""
+ key = db.Column(db.String(64), primary_key=True)
+ value_string = db.Column(db.String(512), nullable=True)
+ value_int = db.Column(db.Integer, nullable=True)
+ value_float = db.Column(db.Float, nullable=True)
+ value_bool = db.Column(db.Boolean, nullable=True)
+ type = db.Column(db.String(16), nullable=False)
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ def get_value(self):
+ """Get typed value based on type field."""
+ if self.type == 'string':
+ return self.value_string
+ elif self.type == 'int':
+ return self.value_int
+ elif self.type == 'float':
+ return self.value_float
+ elif self.type == 'bool':
+ return self.value_bool
+ return None
+
+ def set_value(self, value):
+ """Set value in the appropriate field based on type."""
+ if value is None:
+ return
+
+ if self.type == 'string':
+ self.value_string = str(value)
+ elif self.type == 'int':
+ self.value_int = int(value)
+ elif self.type == 'float':
+ self.value_float = float(value)
+ elif self.type == 'bool':
+ self.value_bool = bool(value)
+
+ def to_dict(self):
+ """Convert setting to dictionary."""
+ return {
+ 'key': self.key,
+ 'value': self.get_value(),
+ 'type': self.type,
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
+ 'updated_at': self.updated_at.isoformat() if self.updated_at else None
+ }
\ No newline at end of file
diff --git a/app/printer_manager.py b/app/printer_manager.py
new file mode 100644
index 0000000..b4a54d3
--- /dev/null
+++ b/app/printer_manager.py
@@ -0,0 +1,1153 @@
+"""
+Printer management module for ChitUI
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import platform
+import socket
+import subprocess
+import time
+import uuid
+from collections import defaultdict
+from threading import Lock, Thread
+from typing import Any, Dict, List, Optional
+
+import requests
+import websocket
+from loguru import logger
+from tqdm import tqdm
+import threading
+
+from app import printers, websockets, upload_progress
+from app.constants import CAMERA_ENABLED_MODELS, CMD, MACHINE_STATUS, PRINTER_ICONS
+
+# Configure logging
+import logging
+logging.basicConfig(
+ level=logging.DEBUG,
+ format='%(asctime)s | %(levelname)-8s | %(message)s',
+ handlers=[
+ logging.FileHandler('printer_debug.log'),
+ logging.StreamHandler()
+ ]
+)
+
+try:
+ import netifaces
+ _HAS_NETIFACES = True
+except ImportError:
+ _HAS_NETIFACES = False
+ logger.warning("netifaces not installed; falling back to RFC1918 subnets")
+
+# βββββββββββββββββββββββββββ Globals ββ
+printer_lock = Lock()
+job_queues: Dict[str, List[str]] = defaultdict(list) # printer_id β queue of filenames
+
+WS_PORTS = (3030, 3031) # Elegoo 14 K listens on 3031
+UDP_DISCOVERY_PORT = 3000
+UDP_LISTEN_PORT = 54781
+UDP_MSG = b"M99999"
+WS_TIMEOUT = 6
+CHUNK_SIZE = 1048576 # 1MB chunks for file uploads
+SOCKET_TIMEOUT = 2
+DEFAULT_TIMEOUT = 2
+MAX_RETRIES = 2
+MAX_WORKERS = 8
+
+# ============================================================================
+# Lowβlevel helpers
+# ----------------------------------------------------------------------------
+
+def _ping(ip: str) -> bool:
+ """Return *True* if host responds to a single ICMP ping."""
+ param = "-n" if platform.system().lower() == "windows" else "-c"
+ return subprocess.run(["ping", param, "1", ip], capture_output=True).returncode == 0
+
+
+def _ws_send(pid: str, cmd: int | str, data: dict | None = None) -> bool:
+ """Serialize and send an SDCP command over the cached WebSocket."""
+ if pid not in websockets:
+ logger.warning(f"No WS for {pid}")
+ return False
+ ws = websockets[pid]
+ cmd_code = CMD[cmd] if isinstance(cmd, str) else cmd
+ payload = {
+ "Id": printers[pid]["connection"],
+ "Data": {
+ "Cmd": cmd_code,
+ "Data": data or {},
+ "RequestID": os.urandom(8).hex(),
+ "MainboardID": pid,
+ "TimeStamp": int(time.time()),
+ "From": 0,
+ },
+ "Topic": f"sdcp/request/{pid}",
+ }
+ try:
+ ws.send(json.dumps(payload))
+ logger.debug(f"β {cmd} to {pid}")
+ return True
+ except Exception as exc:
+ logger.error(f"Send {cmd} failed: {exc}")
+ return False
+
+
+# ============================================================================
+# Discovery & manualβadd
+# ----------------------------------------------------------------------------
+
+def discover_printers(timeout=2):
+ """Broadcast UDP packet and collect printer beacons with improved network handling."""
+ logger.info("Starting enhanced printer discovery")
+
+ discovered = {}
+
+ try:
+ # Get all network interfaces
+ network_interfaces = []
+
+ try:
+ # Try using netifaces for better network interface detection
+ import netifaces
+ for iface in netifaces.interfaces():
+ addrs = netifaces.ifaddresses(iface)
+ if netifaces.AF_INET in addrs:
+ for addr in addrs[netifaces.AF_INET]:
+ if 'addr' in addr and 'broadcast' in addr:
+ network_interfaces.append({
+ 'name': iface,
+ 'ip': addr['addr'],
+ 'broadcast': addr['broadcast']
+ })
+ logger.debug(f"Found interface: {iface} ({addr['addr']}) - broadcast: {addr['broadcast']}")
+ except ImportError:
+ # Fallback to common broadcast addresses
+ logger.info("Netifaces not available, using fallback addresses")
+ network_interfaces = [
+ {'name': 'default', 'ip': '0.0.0.0', 'broadcast': '255.255.255.255'},
+ {'name': 'subnet-0', 'ip': '0.0.0.0', 'broadcast': '192.168.0.255'},
+ {'name': 'subnet-1', 'ip': '0.0.0.0', 'broadcast': '192.168.1.255'}
+ ]
+
+ # If no interfaces found, use default
+ if not network_interfaces:
+ network_interfaces = [
+ {'name': 'default', 'ip': '0.0.0.0', 'broadcast': '255.255.255.255'}
+ ]
+
+ # Also perform a direct scan to the specific printer IP if provided as environment variable
+ specific_ip = os.environ.get('PRINTER_IP')
+ if specific_ip:
+ logger.info(f"Attempting direct discovery for IP: {specific_ip}")
+ direct_result = discover_printer_by_ip(specific_ip, timeout)
+ discovered.update(direct_result)
+
+ # Scan all interfaces in parallel
+ threads = []
+ thread_lock = threading.Lock()
+
+ for interface in network_interfaces:
+ thread = threading.Thread(
+ target=scan_interface,
+ args=(interface, timeout, discovered, thread_lock)
+ )
+ thread.daemon = True
+ threads.append(thread)
+ thread.start()
+
+ # Wait for all threads to complete
+ for thread in threads:
+ thread.join()
+
+ except Exception as e:
+ logger.error(f"Discovery failed: {e}")
+
+ logger.info(f"Discovery completed: {len(discovered)} printers found")
+ return discovered
+
+def scan_interface(interface: Dict[str, str],
+ timeout: float,
+ discovered: Dict[str, Any],
+ lock: threading.Lock):
+ """
+ (Your original logic, unchanged)
+ """
+ interface_name = interface['name']
+ source_ip = interface['ip']
+ broadcast = interface['broadcast']
+
+ logger.debug(f"Scanning {interface_name}: {source_ip} β {broadcast}")
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.settimeout(timeout)
+ try:
+ sock.bind((source_ip, 0))
+ except socket.error as e:
+ logger.warning(f"Bind {source_ip} failed: {e}; using default")
+ sock.bind(('', 0))
+ sock.sendto(UDP_MSG, (broadcast, UDP_DISCOVERY_PORT))
+ start = time.time()
+ while time.time() - start < timeout:
+ try:
+ data, addr = sock.recvfrom(8192)
+ j = json.loads(data.decode('utf-8'))
+ pid = (
+ j.get('Data', {}).get('MainboardID')
+ or j.get('Data', {}).get('Attributes', {}).get('MainboardID')
+ )
+ if pid:
+ logger.info(f"Discovered {pid} at {addr[0]} via {interface_name}")
+ with lock:
+ if pid not in discovered:
+ discovered[pid] = save_discovered_printer(data, addr)
+ except socket.timeout:
+ break
+ except json.JSONDecodeError as je:
+ logger.warning(f"Bad JSON from {addr[0]}: {je}")
+ except Exception as e:
+ logger.warning(f"Scan error from {addr[0]}: {e}")
+ except Exception as e:
+ logger.warning(f"Socket error on {interface_name}: {e}")
+
+def discover_printer_by_ip(ip: str, timeout: float = DEFAULT_TIMEOUT) -> Dict[str, Any]:
+ """
+ (Your original direct-IP logic, unchanged)
+ """
+ logger.info(f"Attempting direct discovery for printer at {ip}")
+ discovered = {}
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.settimeout(timeout)
+ sock.sendto(UDP_MSG, (ip, UDP_DISCOVERY_PORT))
+ try:
+ data, addr = sock.recvfrom(8192)
+ j = json.loads(data.decode('utf-8'))
+ pid = (
+ j.get('Data', {}).get('MainboardID')
+ or j.get('Data', {}).get('Attributes', {}).get('MainboardID')
+ )
+ if pid:
+ logger.info(f"Directly discovered {pid} at {addr[0]}")
+ discovered[pid] = save_discovered_printer(data, addr)
+ except socket.timeout:
+ logger.warning(f"No response from {ip}")
+ except Exception as e:
+ logger.error(f"Direct discovery error for {ip}: {e}")
+ return discovered
+
+
+def save_discovered_printer(data, addr=None):
+ """Parse printer data with robust error handling."""
+ try:
+ if isinstance(data, bytes):
+ j = json.loads(data.decode('utf-8'))
+ else:
+ j = data
+
+ # Handle different response formats
+ if 'Data' in j:
+ if 'Attributes' in j['Data']:
+ # Standard format
+ printer = {
+ 'connection': j['Id'],
+ 'name': j['Data']['Attributes'].get('Name', 'Unknown Printer'),
+ 'model': j['Data']['Attributes'].get('MachineName', 'Unknown Model'),
+ 'brand': j['Data'].get('BrandName', 'ELEGOO'),
+ 'ip': j['Data'].get('MainboardIP', addr[0] if addr else 'Unknown'),
+ 'protocol': j['Data'].get('ProtocolVersion', 'Unknown'),
+ 'firmware': j['Data'].get('FirmwareVersion', 'Unknown'),
+ 'status': 'disconnected',
+ 'last_seen': time.time(),
+ 'files': {} # Initialize files dictionary
+ }
+ else:
+ # Alternative format
+ printer = {
+ 'connection': j['Id'],
+ 'name': j['Data'].get('Name', 'Unknown Printer'),
+ 'model': j['Data'].get('MachineName', 'Unknown Model'),
+ 'brand': j['Data'].get('BrandName', 'ELEGOO'),
+ 'ip': j['Data'].get('MainboardIP', addr[0] if addr else 'Unknown'),
+ 'protocol': j['Data'].get('ProtocolVersion', 'Unknown'),
+ 'firmware': j['Data'].get('FirmwareVersion', 'Unknown'),
+ 'status': 'disconnected',
+ 'last_seen': time.time(),
+ 'files': {} # Initialize files dictionary
+ }
+ else:
+ # Fallback format
+ printer = {
+ 'connection': j.get('Id', 'Unknown'),
+ 'name': 'Unknown Printer',
+ 'model': 'Unknown Model',
+ 'brand': 'ELEGOO',
+ 'ip': addr[0] if addr else 'Unknown',
+ 'protocol': 'Unknown',
+ 'firmware': 'Unknown',
+ 'status': 'disconnected',
+ 'last_seen': time.time(),
+ 'files': {} # Initialize files dictionary
+ }
+
+ # Extract printer ID
+ if 'Data' in j and 'MainboardID' in j['Data']:
+ printer_id = j['Data']['MainboardID']
+ elif 'Data' in j and 'Attributes' in j['Data'] and 'MainboardID' in j['Data']['Attributes']:
+ printer_id = j['Data']['Attributes']['MainboardID']
+ else:
+ printer_id = f"unknown_{addr[0]}" if addr else f"unknown_{uuid.uuid4()}"
+
+ logger.info(f"Discovered: {printer['name']} ({printer['ip']})")
+
+ if printer_id:
+ printers[printer_id] = printer
+
+ return printer
+ except Exception as e:
+ logger.error(f"Error parsing printer data: {e}")
+
+ # Create minimal printer entry on error
+ if addr:
+ minimal_printer = {
+ 'connection': f"manual_{uuid.uuid4().hex[:8]}",
+ 'name': f"Printer at {addr[0]}",
+ 'model': 'Unknown Model',
+ 'brand': 'ELEGOO',
+ 'ip': addr[0],
+ 'protocol': 'Unknown',
+ 'firmware': 'Unknown',
+ 'status': 'disconnected',
+ 'last_seen': time.time(),
+ 'files': {} # Initialize files dictionary
+ }
+ return minimal_printer
+ return None
+def add_printer_manually(name: str, ip: str, model: str = "unknown", brand: str = "unknown") -> Dict[str, Any]:
+ """Manually add a printer when UDP broadcast is disabled."""
+ pid = hashlib.md5(ip.encode()).hexdigest()
+
+ printer: Dict[str, Any] = {
+ "id": pid,
+ "connection": f"manual_{uuid.uuid4().hex[:8]}",
+ "name": name,
+ "model": model.lower(),
+ "brand": brand.lower(),
+ "ip": ip,
+ "protocol": "unknown",
+ "firmware": "unknown",
+ "status": "disconnected",
+ "last_seen": time.time(),
+ "machine_status": None,
+ "print_status": None,
+ "files": {},
+ "print_progress": None,
+ "current_file": None,
+ "remain_time": None,
+ "manually_added": True,
+ "supports_camera": False,
+ }
+
+ icon_key = f"{printer['brand']}_{printer['model']}".replace(" ", "")
+ printer["icon"] = PRINTER_ICONS.get(icon_key, PRINTER_ICONS["default"])
+
+ for mdl, cfg in CAMERA_ENABLED_MODELS.items():
+ if mdl in printer["model"]:
+ printer["supports_camera"] = True
+ printer["camera_config"] = cfg
+ break
+
+ # Simple reachability check
+ if not _ping(ip):
+ logger.warning(f"{ip} is not reachable β adding anyway (manual)")
+
+ with printer_lock:
+ printers[pid] = printer
+ Thread(target=lambda: connect_printer(pid), daemon=True).start()
+ return printer
+
+# ============================================================================
+# Connection & WebSocket handlers
+# ----------------------------------------------------------------------------
+
+def connect_printer(pid: str) -> bool:
+ """Connect to a specific printer by ID."""
+ if pid not in printers:
+ logger.error(f"Unknown printer {pid}")
+ return False
+
+ printer = printers[pid]
+ logger.info(f"Connecting to printer: {printer['name']} ({printer['ip']})")
+
+ websocket.setdefaulttimeout(WS_TIMEOUT)
+
+ for port in WS_PORTS:
+ url = f"ws://{printer['ip']}:{port}/websocket"
+ logger.info(f"βΆ Trying connection on {url}")
+
+ try:
+ ws = websocket.WebSocketApp(
+ url,
+ on_open=lambda *_: _ws_open(pid),
+ on_message=lambda _, msg: _ws_message(pid, msg),
+ on_close=lambda _, c, m: _ws_close(pid, c, m),
+ on_error=lambda _, err: _ws_error(pid, err),
+ )
+ websockets[pid] = ws
+ Thread(target=lambda: ws.run_forever(reconnect=5, ping_interval=30, ping_timeout=10), daemon=True).start()
+ return True
+ except OSError as exc:
+ logger.warning(f"Port {port} failed: {exc}")
+ continue
+
+ logger.error(f"All WS ports failed for {printer['name']} ({printer['ip']})")
+ return False
+
+
+# β WebSocket event callbacks β
+
+def _ws_open(pid: str):
+ """WebSocket open event handler."""
+ with printer_lock:
+ printers[pid]["status"] = "connected"
+ printers[pid]["last_seen"] = time.time()
+
+ logger.info(f"WS connected: {printers[pid]['name']}")
+
+ # Request initial status and attributes
+ _ws_send(pid, "STATUS")
+ _ws_send(pid, "ATTRIBUTES")
+ _ws_send(pid, "FILE_LIST", {"Url": "/local"})
+
+
+def _ws_close(pid: str, code: int, msg: str):
+ """WebSocket close event handler."""
+ with printer_lock:
+ printers[pid]["status"] = "disconnected"
+ logger.info(f"WS closed ({code}): {printers[pid]['name']} β {msg}")
+
+
+def _ws_error(pid: str, err: Exception):
+ """WebSocket error event handler."""
+ logger.error(f"WS error for {pid}: {err}")
+
+
+def _ws_message(pid: str, raw: str):
+ """WebSocket message handler."""
+ if pid not in printers:
+ logger.warning(f"Received message for unknown printer: {pid}")
+ return
+
+ try:
+ # Parse message with error handling
+ try:
+ packet = json.loads(raw)
+ except json.JSONDecodeError as json_err:
+ logger.error(f"Failed to parse JSON message for printer {pid}: {json_err}")
+ logger.error(f"Problematic message: {raw}")
+ return
+
+ # Log full message for debugging
+ logger.debug(f"Received message from printer {pid}: {json.dumps(packet, indent=2)}")
+
+ # Process message topic
+ topic = packet.get("Topic", "")
+
+ HANDLERS = {
+ "sdcp/status/": _on_status,
+ "sdcp/attributes/": _on_attributes,
+ "sdcp/response/": _on_response,
+ "sdcp/error/": _on_error,
+ "sdcp/notice/": _on_notice,
+ }
+
+ # Route message to correct handler
+ for prefix, handler in HANDLERS.items():
+ if topic.startswith(prefix):
+ try:
+ handler(pid, packet)
+ except Exception as process_err:
+ logger.error(f"Error processing {topic} message for printer {pid}: {process_err}")
+ logger.error(f"Problematic message data: {json.dumps(packet, indent=2)}")
+ return
+
+ logger.warning(f"Received unknown message topic: {topic}")
+
+ except Exception as e:
+ logger.error(f"Unexpected error in WebSocket message handler for printer {pid}: {e}")
+ logger.error(f"Original message: {raw}")
+
+
+# ============================================================================
+# Message processors
+# ----------------------------------------------------------------------------
+
+def _on_status(pid: str, packet: Dict[str, Any]):
+ """Process printer status messages."""
+ status = packet.get("Status", {})
+
+ # Parse machine status code
+ current_status = status.get("CurrentStatus")
+
+ # Normalize current_status to a single integer
+ machine_status_code = 0
+ if current_status is not None:
+ if isinstance(current_status, list):
+ # If it's a list, try to get the first element
+ try:
+ machine_status_code = int(current_status[0]) if current_status else 0
+ except (IndexError, ValueError, TypeError):
+ machine_status_code = 0
+ elif isinstance(current_status, (int, str)):
+ try:
+ machine_status_code = int(current_status)
+ except (ValueError, TypeError):
+ machine_status_code = 0
+
+ # Get machine status name
+ machine_status_name = MACHINE_STATUS.get(
+ machine_status_code,
+ {"name": "UNKNOWN"}
+ )["name"]
+
+ # Detect finish transition
+ finished = False
+
+ with printer_lock:
+ p = printers[pid]
+ prev = p.get("machine_status")
+ p["machine_status"] = machine_status_name
+
+ if machine_status_name == "PRINTING":
+ info = status.get("PrintInfo", {})
+ p["print_status"] = info.get("PrintStatus")
+ p["current_file"] = info.get("Filename")
+
+ # Calculate progress safely
+ try:
+ layer = int(info.get("Layer", 0))
+ total_layers = int(info.get("TotalLayer", 1)) or 1
+ p["print_progress"] = round((layer / total_layers) * 100)
+ except (TypeError, ValueError):
+ p["print_progress"] = None
+
+ p["remain_time"] = info.get("RemainTime")
+
+ elif machine_status_name == "PAUSED":
+ p["print_status"] = "PAUSED"
+
+ elif machine_status_name == "IDLE":
+ finished = prev == "PRINTING" # Just completed a job
+ p["print_status"] = None
+ p["current_file"] = None
+ p["print_progress"] = None
+ p["remain_time"] = None
+
+ # Emit status update to all clients
+ from app import socketio
+ socketio.emit("printer_status", {
+ "id": pid,
+ "status": printers[pid]["status"],
+ "machine_status": printers[pid]["machine_status"],
+ "print_status": printers[pid]["print_status"],
+ "print_progress": printers[pid]["print_progress"],
+ "current_file": printers[pid]["current_file"],
+ "remain_time": printers[pid]["remain_time"],
+ })
+
+ # Auto-start next in queue after completion
+ if finished:
+ _start_next(pid)
+
+
+def _on_attributes(pid: str, packet: Dict[str, Any]):
+ """Process printer attributes messages."""
+ attrs = packet.get("Attributes", {})
+
+ with printer_lock:
+ # Update printer attributes
+ printers[pid].update({
+ "resolution": attrs.get("Resolution"),
+ "build_volume": attrs.get("XYZsize"),
+ "camera_status": attrs.get("CameraStatus") == 1,
+ })
+
+ # Emit attributes update to all clients
+ from app import socketio
+ socketio.emit("printer_attributes", {"id": pid, "attributes": attrs})
+
+
+def _on_response(pid: str, packet: Dict[str, Any]):
+ """Process printer response messages."""
+ cmd_data = packet.get("Data", {})
+ cmd = cmd_data.get("Cmd")
+ response_data = cmd_data.get("Data", {})
+
+ # Process file list response
+ if cmd == CMD["FILE_LIST"]:
+ url = response_data.get("Url", "/local")
+ with printer_lock:
+ # Fix: Use FileList from response_data
+ printers[pid]["files"][url] = response_data.get("FileList", [])
+
+ # Emit response to all clients
+ from app import socketio
+ socketio.emit("printer_response", {
+ "id": pid,
+ "cmd": cmd,
+ "data": response_data
+ })
+
+
+def _on_error(pid: str, packet: Dict[str, Any]):
+ """Process printer error messages."""
+ error_data = packet.get("Data", {}).get("Data", {})
+ error_code = error_data.get("ErrorCode")
+ error_str = error_data.get("ErrorStr", f"Error code: {error_code}")
+
+ logger.error(f"Printer error ({pid}): {error_str}")
+
+ # Emit error to all clients
+ from app import socketio
+ socketio.emit("printer_error", {
+ "id": pid,
+ "error_code": error_code,
+ "error_message": error_str
+ })
+
+
+def _on_notice(pid: str, packet: Dict[str, Any]):
+ """Process printer notice messages."""
+ notice_data = packet.get("Data", {}).get("Data", {})
+ message = notice_data.get("Message", "Notification from printer")
+
+ logger.info(f"Printer notice ({pid}): {message}")
+
+ # Emit notice to all clients
+ from app import socketio
+ socketio.emit("printer_notice", {
+ "id": pid,
+ "message": message
+ })
+
+
+# ============================================================================
+# Jobβqueue helpers
+# ----------------------------------------------------------------------------
+
+def queue_print(pid: str, filename: str):
+ """Enqueue *filename*; start immediately if printer idle."""
+ with printer_lock:
+ if printers[pid]["machine_status"] not in ("PRINTING", "PAUSED"):
+ # Idle β print immediately
+ logger.info(f"Queue empty, starting {filename} on {pid}")
+ _ws_send(pid, "START_PRINT", {"Filename": filename, "StartLayer": 0})
+ else:
+ # Add to queue
+ job_queues[pid].append(filename)
+ logger.info(f"Queued {filename} on {pid} (len={len(job_queues[pid])})")
+
+ _broadcast_queue(pid)
+
+
+def _start_next(pid: str):
+ """Start the next print job in the queue."""
+ with printer_lock:
+ if job_queues[pid]:
+ nxt = job_queues[pid].pop(0)
+ logger.info(f"Autoβstarting next job {nxt} on {pid}")
+ _ws_send(pid, "START_PRINT", {"Filename": nxt, "StartLayer": 0})
+
+ _broadcast_queue(pid)
+
+
+def _broadcast_queue(pid: str):
+ """Broadcast current queue status to clients."""
+ from app import socketio
+ socketio.emit("printer_queue", {"id": pid, "queue": list(job_queues[pid])})
+
+ if not job_queues[pid]:
+ socketio.emit("queue_empty", {"id": pid})
+
+
+# ============================================================================
+# Public API wrappers
+# ----------------------------------------------------------------------------
+
+def get_printer_status(pid: str) -> bool:
+ """Request printer status."""
+ return _ws_send(pid, "STATUS")
+
+
+def get_printer_attributes(pid: str) -> bool:
+ """Request printer attributes."""
+ return _ws_send(pid, "ATTRIBUTES")
+
+
+def get_printer_files(pid: str, url: str = "/local") -> bool:
+ """Request printer files."""
+ return _ws_send(pid, "FILE_LIST", {"Url": url})
+
+
+def start_print(pid: str, filename: str) -> bool:
+ """Start a print job."""
+ return _ws_send(pid, "START_PRINT", {"Filename": filename, "StartLayer": 0})
+
+
+def pause_print(pid: str) -> bool:
+ """Pause a print job."""
+ return _ws_send(pid, "PAUSE_PRINT")
+
+
+def resume_print(pid: str) -> bool:
+ """Resume a paused print job."""
+ return _ws_send(pid, "RESUME_PRINT")
+
+
+def stop_print(pid: str) -> bool:
+ """Stop a print job."""
+ return _ws_send(pid, "STOP_PRINT")
+
+
+def delete_file(pid: str, filename: str) -> bool:
+ """Delete a file from the printer."""
+ return _ws_send(pid, "DELETE_FILE", {"FileList": [filename]})
+
+
+def set_camera_status(pid: str, enable: bool = True) -> bool:
+ """Enable or disable the camera."""
+ return _ws_send(pid, "CAMERA_CONTROL", {"Enable": 1 if enable else 0})
+
+
+def rename_printer(pid: str, new_name: str) -> bool:
+ """Rename a printer."""
+ result = _ws_send(pid, "RENAME_PRINTER", {"Name": new_name})
+
+ if result and pid in printers:
+ with printer_lock:
+ printers[pid]['name'] = new_name
+
+ return result
+
+
+# ============================================================================
+# Upload and diagnostics utilities
+# ----------------------------------------------------------------------------
+
+def upload_file_to_printer(task_id: str, pid: str, filepath: str):
+ """
+ Upload a file to the printer.
+
+ Args:
+ task_id (str): Upload task ID
+ pid (str): Printer ID
+ filepath (str): Path to file
+ """
+ if pid not in printers:
+ logger.error(f"Cannot upload file to printer {pid}: not found")
+ update_upload_progress(task_id, 0, "error", f"Printer {pid} not found")
+ return
+
+ printer = printers[pid]
+
+ # Verify file exists
+ if not os.path.exists(filepath):
+ logger.error(f"File not found: {filepath}")
+ update_upload_progress(task_id, 0, "error", "File not found")
+ return
+
+ # Calculate MD5 hash
+ try:
+ md5_hash = hashlib.md5()
+ with open(filepath, "rb") as f:
+ for byte_block in iter(lambda: f.read(4096), b""):
+ md5_hash.update(byte_block)
+ except Exception as e:
+ logger.error(f"Error calculating MD5 hash: {e}")
+ update_upload_progress(task_id, 0, "error", f"File read error: {str(e)}")
+ return
+
+ file_stats = os.stat(filepath)
+ filename = os.path.basename(filepath)
+
+ # Upload parameters
+ post_data = {
+ 'S-File-MD5': md5_hash.hexdigest(),
+ 'Check': 1,
+ 'Offset': 0,
+ 'Uuid': str(uuid.uuid4()),
+ 'TotalSize': file_stats.st_size,
+ }
+
+ url = f'http://{printer["ip"]}:3030/uploadFile/upload'
+ num_parts = (int)(file_stats.st_size / CHUNK_SIZE)
+ logger.info(f"Uploading file {filename} to printer {printer['name']} in {num_parts} parts")
+
+ # Update progress
+ update_upload_progress(task_id, 0, "uploading", f"Starting upload to {printer['name']}")
+
+ # Upload parts
+ try:
+ # For Elegoo Saturn Ultra 16K - special handling
+ if "saturnultra16k" in printer.get('model', '').lower() or "saturn" in printer.get('model', '').lower() and "16k" in printer.get('model', '').lower():
+ logger.info(f"Using special upload handling for Saturn Ultra 16K")
+ # Some Elegoo printers need specific headers
+ extra_headers = {
+ 'User-Agent': 'Mozilla/5.0',
+ 'Accept': '*/*'
+ }
+ else:
+ extra_headers = {}
+
+ with tqdm(total=num_parts, desc=f"Uploading {filename}") as pbar:
+ for i in range(num_parts + 1):
+ offset = i * CHUNK_SIZE
+ progress = round(i / (num_parts + 1) * 100)
+
+ # Update progress
+ update_upload_progress(
+ task_id, progress, "uploading", f"Uploading part {i}/{num_parts}")
+
+ with open(filepath, 'rb') as f:
+ f.seek(offset)
+ file_part = f.read(CHUNK_SIZE)
+
+ if not upload_file_part(url, post_data, filename, file_part, offset, extra_headers):
+ logger.error(f"Failed to upload part {i}/{num_parts} of file {filename}")
+ update_upload_progress(task_id, progress, "error", "Upload failed")
+ break
+
+ pbar.update(1)
+
+ # Cleanup
+ os.remove(filepath)
+
+ # Update progress
+ update_upload_progress(task_id, 100, "complete", "Upload complete")
+
+ # Refresh file list
+ get_printer_files(pid, '/local')
+
+ logger.info(f"File {filename} uploaded successfully to printer {printer['name']}")
+
+ except Exception as e:
+ logger.error(f"Error uploading file {filename} to printer {printer['name']}: {e}")
+ update_upload_progress(task_id, 0, "error", str(e))
+
+ # Cleanup
+ if os.path.exists(filepath):
+ os.remove(filepath)
+
+
+def upload_file_part(url: str, post_data: dict, file_name: str, file_part: bytes, offset: int,
+ extra_headers: Optional[dict] = None) -> bool:
+ """
+ Upload a part of a file to the printer.
+
+ Args:
+ url (str): Upload URL
+ post_data (dict): POST data
+ file_name (str): File name
+ file_part (bytes): File part data
+ offset (int): File offset
+ extra_headers (dict, optional): Additional headers
+
+ Returns:
+ bool: True if successful, False otherwise
+ """
+ try:
+ # Set offset
+ post_data['Offset'] = offset
+
+ # Create multipart form data
+ post_files = {'File': (file_name, file_part)}
+
+ # Set headers
+ headers = {}
+ if extra_headers:
+ headers.update(extra_headers)
+
+ # Send request with retry mechanism
+ max_retries = 3
+ retry_count = 0
+
+ while retry_count < max_retries:
+ try:
+ # Send request
+ response = requests.post(
+ url, data=post_data, files=post_files, headers=headers, timeout=10)
+ response.raise_for_status()
+
+ # Parse response
+ try:
+ status = response.json()
+
+ if status.get('success'):
+ return True
+ else:
+ logger.error(f"Upload failed: {status}")
+ return False
+ except ValueError:
+ # Some printers don't return JSON
+ if response.status_code == 200:
+ return True
+ else:
+ logger.error(
+ f"Upload failed: Non-JSON response with status {response.status_code}")
+ return False
+
+ except requests.exceptions.RequestException as e:
+ retry_count += 1
+ logger.warning(f"Upload attempt {retry_count} failed: {e}")
+ if retry_count >= max_retries:
+ logger.error(f"Upload failed after {max_retries} attempts")
+ return False
+ # Wait before retrying
+ time.sleep(2)
+
+ except Exception as e:
+ logger.error(f"Error uploading file part: {e}")
+ return False
+
+
+def update_upload_progress(task_id: str, progress: int, status: str, message: str = ""):
+ """
+ Update upload progress.
+
+ Args:
+ task_id (str): Upload task ID
+ progress (int): Progress percentage (0-100)
+ status (str): Status string (starting, uploading, complete, error)
+ message (str, optional): Progress message
+ """
+ if task_id not in upload_progress:
+ logger.warning(f"Cannot update progress for unknown task {task_id}")
+ return
+
+ with printer_lock:
+ upload_progress[task_id].update({
+ 'progress': progress,
+ 'status': status,
+ 'message': message,
+ 'updated_at': time.time()
+ })
+
+ # Emit progress to all clients
+ from app import socketio
+ socketio.emit('upload_progress', upload_progress[task_id])
+
+
+def debug_printer_connection(ip: str) -> dict:
+ """
+ Debug printer connection issues.
+
+ Args:
+ ip (str): Printer IP address
+
+ Returns:
+ dict: Diagnostic results
+ """
+ try:
+ logger.info(f"Testing connection to printer at {ip}")
+ results = {
+ "ping": {"status": "not_tested", "message": ""},
+ "http": {"status": "not_tested", "message": ""},
+ "websocket": {"status": "not_tested", "message": ""},
+ "overall": {"status": "not_tested", "message": ""}
+ }
+
+ # Test ping
+ try:
+ logger.info(f"Pinging {ip}...")
+ ping_result = subprocess.run(['ping', '-c', '2', '-W', '2', ip],
+ capture_output=True, text=True)
+ if ping_result.returncode == 0:
+ results["ping"]["status"] = "success"
+ results["ping"]["message"] = "Ping successful"
+ logger.info(f"Ping successful")
+ else:
+ results["ping"]["status"] = "failed"
+ results["ping"]["message"] = "Ping failed"
+ logger.warning(f"Ping failed: {ping_result.stderr}")
+ except Exception as ping_err:
+ results["ping"]["status"] = "error"
+ results["ping"]["message"] = f"Error: {str(ping_err)}"
+ logger.error(f"Ping error: {ping_err}")
+
+ # Test HTTP connection
+ try:
+ http_url = f"http://{ip}:3030/"
+ logger.info(f"Testing HTTP connection to {http_url}")
+ response = requests.get(http_url, timeout=5)
+ results["http"]["status"] = "success" if response.status_code < 400 else "failed"
+ results["http"]["message"] = f"HTTP status: {response.status_code}"
+ logger.info(f"HTTP response: {response.status_code}")
+ except Exception as http_err:
+ results["http"]["status"] = "error"
+ results["http"]["message"] = f"Error: {str(http_err)}"
+ logger.error(f"HTTP connection failed: {http_err}")
+
+ # Test WebSocket connection
+ try:
+ ws_url = f"ws://{ip}:3030/websocket"
+ logger.info(f"Testing WebSocket connection to {ws_url}")
+ websocket.setdefaulttimeout(10)
+ ws = websocket.create_connection(ws_url)
+ results["websocket"]["status"] = "success"
+ results["websocket"]["message"] = "WebSocket connection successful"
+ logger.info("WebSocket connection successful")
+ ws.close()
+ except Exception as ws_err:
+ results["websocket"]["status"] = "error"
+ results["websocket"]["message"] = f"Error: {str(ws_err)}"
+ logger.error(f"WebSocket connection failed: {ws_err}")
+
+ # Determine overall status
+ if results["ping"]["status"] == "success" and (
+ results["http"]["status"] == "success" or
+ results["websocket"]["status"] == "success"):
+ results["overall"]["status"] = "success"
+ results["overall"]["message"] = "Printer is reachable"
+ elif results["ping"]["status"] == "success":
+ results["overall"]["status"] = "partial"
+ results["overall"]["message"] = "Printer is reachable but SDCP services are not responding"
+ else:
+ results["overall"]["status"] = "failed"
+ results["overall"]["message"] = "Printer is not reachable"
+
+ return results
+
+ except Exception as e:
+ logger.error(f"Debug connection error: {e}")
+ return {
+ "ping": {"status": "error", "message": "Test failed"},
+ "http": {"status": "error", "message": "Test failed"},
+ "websocket": {"status": "error", "message": "Test failed"},
+ "overall": {"status": "error", "message": f"Error during diagnostics: {str(e)}"}
+ }
+
+
+def connect_printers():
+ """Connect to all printers in the registry."""
+ connected = 0
+ for printer_id in list(printers.keys()):
+ if connect_printer(printer_id):
+ connected += 1
+
+ logger.info(f"Connected to {connected}/{len(printers)} printers")
+ return connected
+
+def remove_printer(printer_id: str) -> bool:
+ """
+ Remove a printer from the system.
+
+ Args:
+ printer_id (str): Printer ID to remove
+
+ Returns:
+ bool: True if successful, False otherwise
+ """
+ try:
+ # Check if printer exists
+ if printer_id not in printers:
+ logger.warning(f"Attempt to remove non-existent printer: {printer_id}")
+ return False
+
+ # Close any active WebSocket connection
+ if printer_id in websockets:
+ try:
+ websockets[printer_id].close()
+ except Exception as ws_err:
+ logger.warning(f"Error closing WebSocket for printer {printer_id}: {ws_err}")
+ del websockets[printer_id]
+
+ # Remove from any job queues
+ if printer_id in job_queues:
+ del job_queues[printer_id]
+
+ # Get printer info for logging
+ printer_name = printers[printer_id].get('name', 'Unknown printer')
+
+ # Remove printer from in-memory registry
+ del printers[printer_id]
+
+ logger.info(f"Printer {printer_name} (ID: {printer_id}) removed successfully")
+
+ # Emit socket event
+ from app import socketio
+ socketio.emit('printer_removed', {'id': printer_id, 'name': printer_name})
+
+ return True
+ except Exception as e:
+ logger.error(f"Error removing printer {printer_id}: {e}")
+ return False
+
+def get_interfaces() -> List[Dict[str,str]]:
+ """
+ Try netifaces for true broadcast addresses;
+ otherwise auto-derive from your hostβs LAN IPs.
+ """
+ try:
+ import netifaces
+ interfaces = []
+ for iface in netifaces.interfaces():
+ addrs = netifaces.ifaddresses(iface).get(netifaces.AF_INET, [])
+ for addr in addrs:
+ if addr.get('broadcast'):
+ interfaces.append({
+ 'name': iface,
+ 'ip': addr['addr'],
+ 'broadcast': addr['broadcast']
+ })
+ if interfaces:
+ return interfaces
+ except Exception:
+ pass
+
+ # Fallback: probe any RFC1918 class-C youβre on
+ host_ips = socket.gethostbyname_ex(socket.gethostname())[2]
+ subnets = {
+ ip.rsplit('.',1)[0] + '.255'
+ for ip in host_ips
+ if ip.startswith(('10.', '192.168.', '172.'))
+ }
+ return [{'name': 'auto', 'ip': '0.0.0.0', 'broadcast': bc} for bc in subnets]
+
+
+def scan_broadcast(iface: Dict[str,str], timeout: float, discovered: Dict[str,str], lock: threading.Lock):
+ """
+ Send UDP discovery on one broadcast address, collect JSON beacons,
+ retrying once if nothing comes back.
+ """
+ for attempt in range(1, 3):
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.settimeout(timeout)
+ sock.bind((iface['ip'], 0))
+ sock.sendto(UDP_MSG, (iface['broadcast'], UDP_DISCOVERY_PORT))
+
+ start = time.time()
+ while time.time() - start < timeout:
+ try:
+ raw, addr = sock.recvfrom(8192)
+ payload = json.loads(raw.decode('utf-8'))
+ pid = (payload.get('Data', {}) .get('MainboardID')
+ or payload.get('Data', {}) .get('Attributes', {}) .get('MainboardID'))
+ if pid:
+ with lock:
+ if pid not in discovered:
+ discovered[pid] = addr[0]
+ except socket.timeout:
+ break
+ except Exception:
+ continue
+
+ # if we found at least one, no need to retry
+ if discovered:
+ return
+ except Exception:
+ continue
\ No newline at end of file
diff --git a/app/routes.py b/app/routes.py
new file mode 100644
index 0000000..9c30b83
--- /dev/null
+++ b/app/routes.py
@@ -0,0 +1,223 @@
+"""
+Web routes for ChitUI
+"""
+import sys
+import threading
+from flask import Blueprint, flash, render_template, redirect, url_for, request, jsonify, Response, send_from_directory, abort
+from flask_login import login_required, current_user
+from werkzeug.utils import secure_filename
+import os
+import time
+import json
+import uuid
+import requests
+from threading import Thread
+from loguru import logger
+from app import printers, upload_progress
+from app.models import User
+from app.printer_manager import get_printer_files, upload_file_to_printer, set_camera_status
+from app.utils import is_allowed_file, get_config
+
+# Create blueprint - THIS VARIABLE NAME MUST MATCH THE IMPORT IN __init__.py
+routes_bp = Blueprint('routes', __name__)
+
+
+@routes_bp.route('/')
+@login_required
+def index():
+ """Render the main application page."""
+ return render_template('index.html',
+ user=current_user,
+ is_admin=current_user.is_admin())
+
+@routes_bp.route('/viewer')
+@login_required
+def model_viewer():
+ return render_template('viewer.html')
+
+@routes_bp.route('/admin')
+@login_required
+def admin():
+ users = User.query.all()
+ return render_template(
+ 'admin.html',
+ users=users,
+ current_user=current_user
+ )
+
+@routes_bp.route('/admin/restart', methods=['POST'])
+@login_required
+def admin_restart():
+ """Restart the whole Flask process (you need a supervisor to bring it back up)."""
+ if not current_user.is_admin():
+ flash("You don't have permission to do that.", "danger")
+ return redirect(url_for('routes.index'))
+
+ # Spawn a daemon thread that will re-exec the Python process
+ def _delayed_restart():
+ time.sleep(1) # let the HTTP response go out
+ os.execv(sys.executable, [sys.executable] + sys.argv)
+
+ threading.Thread(target=_delayed_restart, daemon=True).start()
+
+ return jsonify({"success": True, "message": "Server is restartingβ¦"})
+
+
+@routes_bp.route('/progress')
+@login_required
+def progress():
+ """Stream upload progress as a server-sent event."""
+ def publish_progress():
+ last_progress = {}
+ while True:
+ for task_id, task in list(upload_progress.items()):
+ # Only send updates when progress changes or for new tasks
+ if task_id not in last_progress or last_progress[task_id] != task['progress']:
+ last_progress[task_id] = task['progress']
+ yield f"data:{json.dumps(task)}\n\n"
+
+ # Remove completed tasks after a while
+ current_time = time.time()
+ for task_id in list(upload_progress.keys()):
+ task = upload_progress[task_id]
+ if task['status'] in ['complete', 'error'] and current_time - task['updated_at'] > 60:
+ del upload_progress[task_id]
+ if task_id in last_progress:
+ del last_progress[task_id]
+
+ time.sleep(1)
+
+ return Response(publish_progress(), mimetype="text/event-stream")
+
+
+@routes_bp.route('/upload', methods=['POST'])
+@login_required
+def upload_file():
+ """Handle file upload to a printer."""
+ if 'file' not in request.files:
+ logger.error("No 'file' parameter in request.")
+ return jsonify({"success": False, "error": "No file part"}), 400
+
+ file = request.files['file']
+ if file.filename == '':
+ logger.error("No file selected to be uploaded.")
+ return jsonify({"success": False, "error": "No file selected"}), 400
+
+ printer_id = request.form.get('printer')
+ if not printer_id or printer_id not in printers:
+ logger.error(f"Invalid printer ID: {printer_id}")
+ return jsonify({"success": False, "error": "Invalid printer ID"}), 400
+
+ if not is_allowed_file(file.filename):
+ logger.error(f"Invalid file type: {file.filename}")
+ return jsonify({"success": False, "error": "Invalid file type"}), 400
+
+ # Save file temporarily
+ filename = secure_filename(file.filename)
+
+ # Get upload folder from config
+ config = get_config()
+ upload_folder = config.get('upload_folder', 'uploads')
+ os.makedirs(upload_folder, exist_ok=True)
+
+ filepath = os.path.join(upload_folder, filename)
+ try:
+ file.save(filepath)
+
+ # Check if file was saved successfully
+ if not os.path.exists(filepath):
+ logger.error(f"Failed to save file: {filepath}")
+ return jsonify({"success": False, "error": "Failed to save file"}), 500
+
+ # Check file size
+ file_size = os.path.getsize(filepath)
+ if file_size == 0:
+ logger.error(f"File is empty: {filepath}")
+ os.remove(filepath)
+ return jsonify({"success": False, "error": "File is empty"}), 400
+
+ logger.info(f"File saved successfully: {filepath} ({file_size} bytes)")
+
+ # Start upload in background thread
+ task_id = str(uuid.uuid4())
+ upload_progress[task_id] = {
+ 'id': task_id,
+ 'filename': filename,
+ 'printer_id': printer_id,
+ 'printer_name': printers[printer_id]['name'],
+ 'status': 'starting',
+ 'progress': 0,
+ 'created_at': time.time(),
+ 'updated_at': time.time()
+ }
+
+ Thread(target=upload_file_to_printer, args=(task_id, printer_id, filepath), daemon=True).start()
+
+ return jsonify({
+ "success": True,
+ "message": "Upload started",
+ "task_id": task_id,
+ "file_size": file_size
+ })
+
+ except Exception as e:
+ logger.error(f"Error during file upload: {e}")
+ # Clean up partial file if it exists
+ if os.path.exists(filepath):
+ try:
+ os.remove(filepath)
+ except:
+ pass
+ return jsonify({"success": False, "error": f"Upload error: {str(e)}"}), 500
+
+
+@routes_bp.route('/camera//stream')
+@login_required
+def camera_stream(printer_id):
+ """Proxy the camera stream from the printer."""
+ if printer_id not in printers:
+ logger.error(f"Invalid printer ID for camera stream: {printer_id}")
+ abort(404)
+
+ printer = printers[printer_id]
+
+ # Check if printer supports camera
+ if not printer.get('supports_camera', False):
+ logger.error(f"Printer {printer_id} does not support camera")
+ abort(404)
+
+ # Enable camera if it's off
+ if not printer.get('camera_status', False):
+ set_camera_status(printer_id, True)
+
+ # Get camera config
+ camera_config = printer.get('camera_config', {})
+ camera_url = camera_config.get('camera_url', '/camera/stream')
+
+ # Proxy the request to the printer
+ try:
+ stream_url = f"http://{printer['ip']}:3030{camera_url}"
+ response = requests.get(stream_url, stream=True, timeout=2)
+
+ if response.status_code != 200:
+ logger.error(f"Failed to get camera stream from printer {printer_id}: {response.status_code}")
+ abort(response.status_code)
+
+ # Forward the content type
+ content_type = response.headers.get('Content-Type', 'image/jpeg')
+
+ # Stream the response
+ def generate():
+ for chunk in response.iter_content(chunk_size=1024):
+ yield chunk
+
+ return Response(generate(), content_type=content_type)
+
+ except Exception as e:
+ logger.error(f"Error proxying camera stream for printer {printer_id}: {e}")
+ abort(500)
+
+
+@routes_bp.route('/static/')
+def static_files(filename):
+ return send_from_directory('../static', filename)
\ No newline at end of file
diff --git a/app/slicer/__init__.py b/app/slicer/__init__.py
new file mode 100644
index 0000000..93f5256
--- /dev/null
+++ b/app/slicer/__init__.py
@@ -0,0 +1 @@
+__init__.py
\ No newline at end of file
diff --git a/app/slicer/goo_format.py b/app/slicer/goo_format.py
new file mode 100644
index 0000000..9efe7ca
--- /dev/null
+++ b/app/slicer/goo_format.py
@@ -0,0 +1,176 @@
+"""
+stl_to_goo_cli.py (singleβfile slicer)
+======================================
+A *streaming* STL β Elegoo **.goo** converter in ~250β―lines of pure
+Python. Each layer is rasterised, runβlengthβencoded, and flushed
+directly to disk, so RAM stays flat even with 12Kβ―Γβ―5K plates.
+
+Quick start
+-----------
+```bash
+pip install "trimesh>=4" pillow shapely typer rich tqdm numpy
+python stl_to_goo_cli.py model.stl -o model.goo
+```
+
+Key features
+------------
+* Works on **huge** displays (default 11β―520β―Γβ―5120 for Saturnβ―4β―Ultra).
+* Streams layers ββ―no giant numpy stack, no MemoryError.
+* Generates valid headers for modern Elegoo machines (Marsβ―4, Saturnβ―4).
+* Zero supports / antiβalias for now β focus is minimal usable core.
+
+Have fun & resin responsibly. PRs to extend the header or add fancy
+features are welcome! π
+"""
+
+from __future__ import annotations
+import struct
+from pathlib import Path
+from datetime import datetime
+from dataclasses import dataclass
+
+import typer
+from PIL import Image, ImageDraw
+from rich.console import Console
+from tqdm import tqdm
+import trimesh
+from shapely.geometry import Polygon, MultiPolygon
+from shapely.ops import unary_union
+
+app = typer.Typer(add_completion=False)
+console = Console()
+
+# ββββββββββββββββββββββββββββ .goo constants ββββββββββββββββββββββββββββ
+MAGIC = b"\x07\x00\x00\x00DLP\x00"
+DELIM = b"\x0D\x0A"
+ENDING = b"\x00\x00\x00\x07\x00\x00\x00DLP\x00"
+
+# βββββββββββββββββββββββββ header helper ββββββββββββββββββββββββββ
+
+
+@dataclass
+class GooHeader:
+ layers: int
+ x_px: int
+ y_px: int
+ x_mm: float
+ y_mm: float
+ z_mm: float
+ layer_h: float
+ exp: float
+ bottom_exp: float = 30.0
+ bottom_layers: int = 3
+
+ def pack(self, small_prev: bytes, big_prev: bytes) -> bytearray:
+ def s(txt: str, n: int) -> bytes:
+ return txt.encode("ascii", "ignore").ljust(n, b"\x00")[:n]
+
+ out = bytearray()
+ out += s("EGOO", 4) + MAGIC
+ out += s("pyβmslicer", 0x20) + s("0.3.0", 0x18)
+ out += s(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 0x18)
+ out += s("Elegoo", 0x20) + s("SaturnΒ 4Β Ultra", 0x20) + \
+ s("StdΒ 0.05Β mm", 0x20)
+ out += struct.pack(">HHH", 1, 1, 0) # antialias, grey, blur
+ out += small_prev + DELIM + big_prev + DELIM
+
+ # pack core numeric block ---------------------
+ core_fmt = (
+ ">IHH??" # layers, x_px, y_px, mirror flags
+ "fff" # bed size (x_mm, y_mm, z_mm)
+ "fff" # layer_h, exp, exposure delay
+ "ffffff" # six motion zeros
+ "fI" # bottom_exp, bottom_layers
+ "ff" # bottom lift dist/speed
+ "ff" # lift dist/speed
+ "ff" # bottom retract dist/speed
+ "ff" # retract dist/speed
+ "ff" # second bottom lift dist/speed
+ "ff" # second lift dist/speed
+ "ff" # second bottom retract dist/speed
+ "ff" # second retract dist/speed
+ "BB" # light PWM
+ "?I" # per-layer flag, print time
+ "fff" # volume, weight, price
+ )
+ args = [
+ self.layers,
+ self.x_px, self.y_px,
+ False, False,
+ self.x_mm, self.y_mm, self.z_mm,
+ self.layer_h, self.exp, 0.0,
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
+ self.bottom_exp, self.bottom_layers,
+ 5.0, 65.0,
+ 5.0, 65.0,
+ 5.0, 150.0,
+ 5.0, 150.0,
+ 0.0, 0.0,
+ 0.0, 0.0,
+ 0.0, 0.0,
+ 0.0, 0.0,
+ 255, 255,
+ False, int(self.layers * (self.exp + 0.5)),
+ 0.0, 0.0, 0.0,
+ ]
+ core = struct.pack(core_fmt, *args)
+ out += core
+ # end core block
+ out += s("USD", 8)truct.pack(">H", l)+bytes([v])
+ i += l
+ return bytes(out)
+
+# βββββββββββββββββββββββββ raster helper ββββββββββββββββββββββββββ
+
+
+def raster(section: trimesh.Path3D | None, x_px: int, y_px: int, x_mm: float, y_mm: float) -> bytes:
+ if not section:
+ return bytes(x_px*y_px)
+ planar = section.to_2D() if hasattr(
+ section, "to_2D") else section.to_planar()[0]
+ polys = planar.polygons_full
+ if not polys:
+ return bytes(x_px*y_px)
+ union = unary_union(polys)
+ img = Image.new("1", (x_px, y_px), 0)
+ draw = ImageDraw.Draw(img)
+ sx, sy = x_px/x_mm, y_px/y_mm
+ rings = ([union.exterior.coords] if isinstance(union, Polygon)
+ else [g.exterior.coords for g in union.geoms])
+ for ring in rings:
+ pts = [(int((x+x_mm/2)*sx), int((y_mm/2-y)*sy)) for x, y in ring]
+ if len(pts) >= 3:
+ draw.polygon(pts, fill=1)
+ return img.tobytes()
+
+# βββββββββββββββββββββββββ CLI main ββββββββββββββββββββββββββ
+
+
+@app.command()
+def convert(
+ stl: Path = typer.Argument(..., help="Input STL"),
+ output: Path = typer.Option("out.goo", "-o", "--output"),
+ x_res: int = 11520, y_res: int = 5120,
+ x_mm: float = 218.88, y_mm: float = 122.904,
+ layer_h: float = 0.05, exp: float = 3.0,
+):
+ console.print(f"[bold]Mesh:[/] {stl}")
+ mesh = trimesh.load_mesh(stl, force="mesh")
+ layers = int((mesh.bounds[1][2]/layer_h)+0.9999)
+ console.print(f"[bold]Layers:[/] {layers}")
+
+ hdr = GooHeader(layers, x_res, y_res, x_mm, y_mm, 220.0, layer_h, exp).pack(
+ bytes(116*116*2), bytes(290*290*2)
+ )
+ with open(output, "wb") as fh:
+ fh.write(hdr)
+ for i in tqdm(range(layers), desc="slicing", unit="lyr"):
+ z = i*layer_h
+ sec = mesh.section([0, 0, z], [0, 0, 1])
+ fh.write(rle(raster(sec, x_res, y_res, x_mm, y_mm)))
+ fh.write(ENDING)
+ console.print(f"[green]β written:[/] {output}")
+
+
+if __name__ == "__main__":
+ app()
diff --git a/app/socket_handlers.py b/app/socket_handlers.py
new file mode 100644
index 0000000..6e25203
--- /dev/null
+++ b/app/socket_handlers.py
@@ -0,0 +1,365 @@
+"""
+WebSocket event handlers for ChitUI
+"""
+from flask_socketio import emit, join_room, leave_room
+from flask_login import current_user
+from loguru import logger
+from threading import Thread
+import time
+from app import printers, upload_progress
+from app.printer_manager import (
+ discover_printers, connect_printers, connect_printer,
+ get_printer_status, get_printer_attributes, get_printer_files,
+ start_print, pause_print, resume_print, stop_print, delete_file,
+ set_camera_status, rename_printer
+)
+
+
+def register_handlers(socketio):
+ """Register all SocketIO event handlers."""
+
+ @socketio.on('connect')
+ def handle_connect():
+ """Handle client connection event."""
+ if not current_user.is_authenticated:
+ return False # Reject connection
+
+ logger.info(f"Client connected: {current_user.username}")
+ emit('printers', printers)
+
+ # Send active uploads
+ for task_id, task in upload_progress.items():
+ if task['status'] not in ['complete', 'error'] or task['progress'] < 100:
+ emit('upload_progress', task)
+
+ @socketio.on('disconnect')
+ def handle_disconnect():
+ """Handle client disconnection event."""
+ if current_user.is_authenticated:
+ logger.info(f"Client disconnected: {current_user.username}")
+
+ @socketio.on('printers')
+ def handle_printers_request(data=None):
+ """Handle printers list request."""
+ logger.info(f"Client requested printer discovery: {current_user.username}")
+
+ # Start discovery in a separate thread
+ Thread(target=discovery_task, daemon=True).start()
+
+ @socketio.on('scan_lan')
+ def handle_scan_lan(data):
+ """Handle LAN scan request."""
+ timeout = data.get('timeout', 3)
+ logger.info(f"Client requested LAN scan with timeout: {timeout}s")
+
+ # Start scan in a separate thread
+ Thread(target=lambda: lan_scan_task(timeout, socketio), daemon=True).start()
+
+ @socketio.on('printer_info')
+ def handle_printer_info(data):
+ """Handle printer info request."""
+ printer_id = data.get('id')
+ if not printer_id or printer_id not in printers:
+ logger.warning(f"Client requested info for unknown printer: {printer_id}")
+ return
+
+ logger.debug(f"Client requested info for printer: {printer_id}")
+ get_printer_status(printer_id)
+ get_printer_attributes(printer_id)
+
+ @socketio.on('printer_files')
+ def handle_printer_files(data):
+ """Handle printer files request."""
+ printer_id = data.get('id')
+ url = data.get('url', '/local')
+
+ if not printer_id or printer_id not in printers:
+ logger.warning(f"Client requested files for unknown printer: {printer_id}")
+ return
+
+ logger.debug(f"Client requested files for printer: {printer_id}, url: {url}")
+ get_printer_files(printer_id, url)
+
+ @socketio.on('action_print')
+ def handle_action_print(data):
+ """Handle print action request."""
+ printer_id = data.get('id')
+ filename = data.get('data')
+
+ if not printer_id or printer_id not in printers:
+ logger.warning(f"Client requested print action for unknown printer: {printer_id}")
+ return
+
+ if not filename:
+ logger.warning(f"Client requested print action with no filename")
+ return
+
+ logger.info(f"Client requested print action: {printer_id}, file: {filename}")
+ start_print(printer_id, filename)
+
+ @socketio.on('action_pause')
+ def handle_action_pause(data):
+ """Handle pause action request."""
+ printer_id = data.get('id')
+
+ if not printer_id or printer_id not in printers:
+ logger.warning(f"Client requested pause action for unknown printer: {printer_id}")
+ return
+
+ logger.info(f"Client requested pause action: {printer_id}")
+ pause_print(printer_id)
+
+ @socketio.on('action_resume')
+ def handle_action_resume(data):
+ """Handle resume action request."""
+ printer_id = data.get('id')
+
+ if not printer_id or printer_id not in printers:
+ logger.warning(f"Client requested resume action for unknown printer: {printer_id}")
+ return
+
+ logger.info(f"Client requested resume action: {printer_id}")
+ resume_print(printer_id)
+
+ @socketio.on('action_stop')
+ def handle_action_stop(data):
+ """Handle stop action request."""
+ printer_id = data.get('id')
+
+ if not printer_id or printer_id not in printers:
+ logger.warning(f"Client requested stop action for unknown printer: {printer_id}")
+ return
+
+ logger.info(f"Client requested stop action: {printer_id}")
+ stop_print(printer_id)
+
+ @socketio.on('action_delete')
+ def handle_action_delete(data):
+ """Handle delete action request."""
+ printer_id = data.get('id')
+ filename = data.get('data')
+
+ if not printer_id or printer_id not in printers:
+ logger.warning(f"Client requested delete action for unknown printer: {printer_id}")
+ return
+
+ if not filename:
+ logger.warning(f"Client requested delete action with no filename")
+ return
+
+ logger.info(f"Client requested delete action: {printer_id}, file: {filename}")
+ delete_file(printer_id, filename)
+
+ @socketio.on('action_camera')
+ def handle_action_camera(data):
+ """Handle camera action request."""
+ printer_id = data.get('id')
+ enable = data.get('enable', True)
+
+ if not printer_id or printer_id not in printers:
+ logger.warning(f"Client requested camera action for unknown printer: {printer_id}")
+ return
+
+ logger.info(f"Client requested camera action: {printer_id}, enable: {enable}")
+ set_camera_status(printer_id, enable)
+
+ @socketio.on('action_rename')
+ def handle_action_rename(data):
+ """Handle rename action request."""
+ printer_id = data.get('id')
+ name = data.get('name')
+
+ if not printer_id or printer_id not in printers:
+ logger.warning(f"Client requested rename action for unknown printer: {printer_id}")
+ return
+
+ if not name:
+ logger.warning("Client requested rename action with no name")
+ return
+
+ logger.info(f"Client requested rename action: {printer_id}, new name: {name}")
+ rename_printer(printer_id, name)
+
+ @socketio.on('remove_printer')
+ def handle_remove_printer(data):
+ """Handle printer removal request."""
+ printer_id = data.get('id')
+
+ if not printer_id or printer_id not in printers:
+ logger.warning(f"Client requested removal for unknown printer: {printer_id}")
+ emit('printer_removal_result', {
+ 'success': False,
+ 'error': 'Printer not found'
+ })
+ return
+
+ logger.info(f"Client requested printer removal: {printer_id}")
+ from app.printer_manager import remove_printer
+ result = remove_printer(printer_id)
+
+ # Send confirmation of action
+ if result:
+ emit('printer_removal_result', {
+ 'success': True,
+ 'printer_id': printer_id
+ })
+ else:
+ emit('printer_removal_result', {
+ 'success': False,
+ 'printer_id': printer_id,
+ 'error': 'Failed to remove printer'
+ })
+
+
+def discovery_task():
+ """Run printer discovery in a separate thread."""
+ # Discover printers
+ discovered = discover_printers()
+
+ # Update printers dict
+ for printer_id, printer in discovered.items():
+ if printer_id not in printers:
+ # New printer
+ printers[printer_id] = printer
+ logger.info(f"New printer discovered: {printer['name']} ({printer['ip']})")
+ else:
+ # Update existing printer
+ current = printers[printer_id]
+ current.update({
+ 'name': printer['name'],
+ 'ip': printer['ip'],
+ 'last_seen': printer['last_seen']
+ })
+ logger.debug(f"Updated printer: {printer['name']} ({printer['ip']})")
+
+ # Connect to printers
+ connect_printers()
+
+ # Emit updated printers list
+ from app import socketio
+ socketio.emit('printers', printers)
+
+
+def lan_scan_task(timeout, socketio):
+ """Run LAN scan in a separate thread with detailed progress updates."""
+ # Emit initial progress
+ socketio.emit('scan_progress', {
+ 'progress': 5,
+ 'status': 'Initializing network scan...',
+ 'complete': False
+ })
+
+ # Start discovery
+ try:
+ # Give UI time to show
+ time.sleep(0.5)
+
+ # Scan network interfaces
+ socketio.emit('scan_progress', {
+ 'progress': 10,
+ 'status': 'Checking network interfaces...',
+ 'details': 'Finding available network interfaces',
+ 'complete': False
+ })
+
+ # Get network interfaces
+ try:
+ import netifaces
+ interfaces = []
+ for iface in netifaces.interfaces():
+ addrs = netifaces.ifaddresses(iface)
+ if netifaces.AF_INET in addrs:
+ for addr in addrs[netifaces.AF_INET]:
+ if 'addr' in addr and 'broadcast' in addr:
+ interfaces.append({
+ 'name': iface,
+ 'ip': addr['addr'],
+ 'broadcast': addr['broadcast']
+ })
+
+ # Update progress with interface info
+ for i, iface in enumerate(interfaces):
+ socketio.emit('scan_progress', {
+ 'progress': 15 + i * 5,
+ 'details': f"Found interface: {iface['name']} ({iface['ip']})",
+ 'complete': False
+ })
+ except ImportError:
+ socketio.emit('scan_progress', {
+ 'progress': 20,
+ 'details': 'Using default network configuration',
+ 'complete': False
+ })
+
+ # Update progress
+ socketio.emit('scan_progress', {
+ 'progress': 30,
+ 'status': 'Broadcasting discovery packets...',
+ 'details': 'Sending UDP broadcast on all interfaces',
+ 'complete': False
+ })
+
+ # Run discovery with increased timeout for more thorough scanning
+ discovered = discover_printers(timeout=timeout)
+
+ # Update progress
+ socketio.emit('scan_progress', {
+ 'progress': 70,
+ 'status': f'Processing {len(discovered)} found devices...',
+ 'details': f'Found {len(discovered)} printers on the network',
+ 'complete': False
+ })
+
+ # Emit each discovered printer
+ for printer_id, printer in discovered.items():
+ socketio.emit('printer_discovered', printer)
+ time.sleep(0.1) # Space out the emissions for UI
+
+ # Update printers dict
+ for printer_id, printer in discovered.items():
+ if printer_id not in printers:
+ # New printer
+ printers[printer_id] = printer
+ logger.info(f"New printer discovered: {printer['name']} ({printer['ip']})")
+ else:
+ # Update existing printer
+ current = printers[printer_id]
+ current.update({
+ 'name': printer['name'],
+ 'ip': printer['ip'],
+ 'last_seen': printer['last_seen']
+ })
+ logger.debug(f"Updated printer: {printer['name']} ({printer['ip']})")
+
+ # Connect to printers
+ socketio.emit('scan_progress', {
+ 'progress': 85,
+ 'status': 'Connecting to discovered printers...',
+ 'details': 'Establishing connections to printers',
+ 'complete': False
+ })
+
+ connect_printers()
+
+ # Emit final progress
+ socketio.emit('scan_progress', {
+ 'progress': 100,
+ 'status': f'Scan complete! Found {len(discovered)} printer(s).',
+ 'details': 'Scan completed successfully',
+ 'complete': True,
+ 'success': len(discovered) > 0
+ })
+
+ # Emit updated printers list
+ socketio.emit('printers', printers)
+
+ except Exception as e:
+ logger.error(f"Error during LAN scan: {e}")
+ socketio.emit('scan_progress', {
+ 'progress': 100,
+ 'status': f'Error during scan: {str(e)}',
+ 'details': f'Exception: {str(e)}',
+ 'complete': True,
+ 'success': False
+ })
+
diff --git a/app/utils.py b/app/utils.py
new file mode 100644
index 0000000..83dda65
--- /dev/null
+++ b/app/utils.py
@@ -0,0 +1,178 @@
+"""
+Utility functions for ChitUI
+"""
+import os
+import yaml
+from pathlib import Path
+from flask import current_app
+from loguru import logger
+
+
+def get_config():
+ """
+ Get configuration from Flask app.
+
+ Returns:
+ dict: Configuration dictionary
+ """
+ try:
+ return current_app.config.get('CHITUI_CONFIG', {})
+ except RuntimeError:
+ # Outside of application context
+ return {}
+
+
+def load_config_file(config_path):
+ """
+ Load configuration from YAML file.
+
+ Args:
+ config_path (str): Path to configuration file
+
+ Returns:
+ dict: Configuration dictionary
+ """
+ if not os.path.exists(config_path):
+ return {}
+
+ try:
+ with open(config_path, 'r') as f:
+ config = yaml.safe_load(f)
+ return config or {}
+ except Exception as e:
+ logger.error(f"Failed to load config from {config_path}: {e}")
+ return {}
+
+
+def save_config_file(config, config_path):
+ """
+ Save configuration to YAML file.
+
+ Args:
+ config (dict): Configuration dictionary
+ config_path (str): Path to configuration file
+
+ Returns:
+ bool: True if successful, False otherwise
+ """
+ try:
+ # Create directory if it doesn't exist
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
+
+ with open(config_path, 'w') as f:
+ yaml.dump(config, f, default_flow_style=False)
+ return True
+ except Exception as e:
+ logger.error(f"Failed to save config to {config_path}: {e}")
+ return False
+
+
+def format_time(seconds):
+ """
+ Format time in seconds to human-readable format.
+
+ Args:
+ seconds (int): Time in seconds
+
+ Returns:
+ str: Formatted time string
+ """
+ if not seconds:
+ return "0m 0s"
+
+ hours = seconds // 3600
+ minutes = (seconds % 3600) // 60
+ remaining_seconds = seconds % 60
+
+ if hours > 0:
+ return f"{hours}h {minutes}m {remaining_seconds}s"
+ else:
+ return f"{minutes}m {remaining_seconds}s"
+
+
+def format_size(size_bytes):
+ """
+ Format file size in bytes to human-readable format.
+
+ Args:
+ size_bytes (int): Size in bytes
+
+ Returns:
+ str: Formatted size string
+ """
+ if not size_bytes:
+ return "0 B"
+
+ size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
+ i = 0
+ while size_bytes >= 1024 and i < len(size_name) - 1:
+ size_bytes /= 1024
+ i += 1
+
+ return f"{size_bytes:.2f} {size_name[i]}"
+
+
+def is_allowed_file(filename):
+ """
+ Check if a file has an allowed extension.
+
+ Args:
+ filename (str): File name
+
+ Returns:
+ bool: True if file extension is allowed, False otherwise
+ """
+ # Get allowed extensions from app config or use defaults
+ from app.constants import ALLOWED_EXTENSIONS
+
+ return '.' in filename and \
+ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
+
+
+def create_default_config():
+ """
+ Create default configuration file if it doesn't exist.
+
+ Returns:
+ dict: Default configuration
+ """
+ config_dir = Path('config')
+ config_file = config_dir / 'config.yaml'
+
+ if config_file.exists():
+ return load_config_file(config_file)
+
+ # Default configuration
+ default_config = {
+ 'host': '0.0.0.0',
+ 'port': 54780,
+ 'debug': False,
+ 'log_level': 'INFO',
+ 'discovery_timeout': 1,
+ 'upload_folder': 'uploads',
+ 'log_folder': 'logs',
+ 'admin_user': 'admin',
+ 'admin_password': 'admin',
+ }
+
+ # Save default configuration
+ config_dir.mkdir(exist_ok=True)
+ save_config_file(default_config, config_file)
+
+ return default_config
+
+def load_config(config_path=None):
+ """
+ Load configuration from YAML file.
+
+ Args:
+ config_path (str, optional): Path to configuration file
+
+ Returns:
+ dict: Configuration dictionary
+ """
+ if not config_path:
+ # Use default config path
+ config_path = os.path.join('config', 'config.yaml')
+
+ return load_config_file(config_path)
\ No newline at end of file
diff --git a/config/default.yaml b/config/default.yaml
new file mode 100644
index 0000000..b3f73ef
--- /dev/null
+++ b/config/default.yaml
@@ -0,0 +1,58 @@
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# ChitUI Settings
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+# Server Settings
+host: "0.0.0.0" # IP or hostname to bind the web server
+port: 54780 # TCP port for the web server
+debug: false # Enable Flask debug mode (auto-reload, detailed errors)
+log_level: "INFO" # Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
+
+# Security
+secret_key: "" # Used by Flask for sessions; auto-generated if empty
+admin_user: "admin" # Initial administrator username
+admin_password: "" # Administrator password; if empty, prompts on first run
+
+# Directory Paths
+upload_folder: "uploads" # Directory for uploaded print files
+log_folder: "logs" # Directory for application logs
+config_folder: "config" # Directory for additional config files
+backup_folder: "backups" # Directory where DB backups are stored
+
+# Database Connection
+# Supports sqlite, MySQL, PostgreSQL
+database_uri: "sqlite:///chitui.db"
+# Example MySQL:
+# database_uri: "mysql+pymysql://user:pass@localhost/chitui"
+# Example PostgreSQL:
+# database_uri: "postgresql+psycopg2://user:pass@localhost/chitui"
+
+# Scheduler & Backup
+auto_backup_enabled: true # Turn on periodic DB backups
+auto_backup_interval: 24 # Hours between automatic backups
+auto_backup_keep: 7 # Number of old backups to keep
+
+# Printer Discovery
+discovery_timeout: 2 # Seconds to wait for network scan responses
+auto_discovery: true # Enable discovery at startup
+
+# Upload Settings
+max_upload_size: "1GB" # Max file upload size (Flask MAX_CONTENT_LENGTH)
+allowed_extensions:
+ - ctb
+ - goo
+ - prz
+
+# Swagger / API Documentation
+swagger_enabled: true # Serve Swagger UI at /swagger
+
+# Scheduler (APScheduler)
+scheduler:
+ timezone: "UTC" # Timezone for scheduled jobs
+ start_on_boot: true # Start APScheduler when app boots
+
+# Custom Settings (stored in DB)
+system_settings:
+ version: "1.0.0"
+ enable_camera_stream: true # Global toggle for camera feeds
+ uploads_enabled: true # Toggle ability to upload files via UI
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..04f7f9b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,23 @@
+version: '3.8'
+
+services:
+ chitui:
+ build: .
+ image: chitui:latest
+ container_name: chitui
+ network_mode: host
+ volumes:
+ - ./config:/app/config
+ - ./logs:/app/logs
+ - ./uploads:/app/uploads
+ - ./backups:/app/backups
+ - ./data:/app/data
+ environment:
+ - PORT=54780
+ - HOST=0.0.0.0
+ - DEBUG=false
+ - LOG_LEVEL=INFO
+ - ADMIN_USER=admin
+ - ADMIN_PASSWORD=admin
+ - DISCOVERY_TIMEOUT=3
+ restart: unless-stopped
\ No newline at end of file
diff --git a/favicon.ico b/favicon.ico
new file mode 100644
index 0000000..ee071b4
Binary files /dev/null and b/favicon.ico differ
diff --git a/main.py b/main.py
index a4cfa9a..05bc123 100644
--- a/main.py
+++ b/main.py
@@ -1,315 +1,223 @@
-from flask import Flask, Response, request, stream_with_context
-from werkzeug.utils import secure_filename
-from flask_socketio import SocketIO
-from threading import Thread
-from loguru import logger
-import socket
-import json
+#!/usr/bin/env python3
+"""
+ChitUI - Web UI for Chitubox SDCP 3.0 resin printers
+
+This is the main entry point for the application, handling CLI arguments
+and starting the web server (with livereload in debug mode).
+"""
import os
-import websocket
-import time
import sys
-import requests
-import hashlib
-import uuid
-
-debug = False
-log_level = "INFO"
-if os.environ.get("DEBUG"):
- debug = True
- log_level = "DEBUG"
-
-logger.remove()
-logger.add(sys.stdout, colorize=debug, level=log_level)
-
-port = 54780
-if os.environ.get("PORT") is not None:
- port = os.environ.get("PORT")
-
-discovery_timeout = 1
-app = Flask(__name__,
- static_url_path='',
- static_folder='web')
-socketio = SocketIO(app)
-websockets = {}
-printers = {}
-
-UPLOAD_FOLDER = '/tmp'
-ALLOWED_EXTENSIONS = {'ctb', 'goo', 'prz'}
-app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
-uploadProgress = 0
-
-
-@app.route("/")
-def web_index():
- return app.send_static_file('index.html')
-
-
-@app.route('/progress')
-def progress():
- def publish_progress():
- while uploadProgress <= 100:
- yield "data:{p}\n\n".format(p=get_upload_progress())
- time.sleep(1)
- return Response(publish_progress(), mimetype="text/event-stream")
-
-
-def get_upload_progress():
- return uploadProgress
-
-
-@app.route('/upload', methods=['GET', 'POST'])
-def upload_file():
- if request.method == 'POST':
- if 'file' not in request.files:
- logger.error("No 'file' parameter in request.")
- return Response('{"upload": "error", "msg": "Malformed request - no file."}', status=400, mimetype="application/json")
- file = request.files['file']
- if file.filename == '':
- logger.error('No file selected to be uploaded.')
- return Response('{"upload": "error", "msg": "No file selected."}', status=400, mimetype="application/json")
- form_data = request.form.to_dict()
- if 'printer' not in form_data or form_data['printer'] == "":
- logger.error("No 'printer' parameter in request.")
- return Response('{"upload": "error", "msg": "Malformed request - no printer."}', status=400, mimetype="application/json")
- printer = printers[form_data['printer']]
- if file and not allowed_file(file.filename):
- logger.error("Invalid filetype.")
- return Response('{"upload": "error", "msg": "Invalid filetype."}', status=400, mimetype="application/json")
-
- filename = secure_filename(file.filename)
- filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
- file.save(filepath)
- logger.debug(
- "File '{f}' received, uploading to printer '{p}'...", f=filename, p=printer['name'])
- upload_file(printer['ip'], filepath)
- return Response('{"upload": "success", "msg": "File uploaded"}', status=200, mimetype="application/json")
- else:
- return Response("u r doin it rong", status=405, mimetype='text/plain')
-
-
-def allowed_file(filename):
- return '.' in filename and \
- filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
-
-
-def upload_file(printer_ip, filepath):
- global uploadProgress
- part_size = 1048576
- filename = os.path.basename(filepath)
- md5_hash = hashlib.md5()
- with open(filepath, "rb") as f:
- for byte_block in iter(lambda: f.read(4096), b""):
- md5_hash.update(byte_block)
- file_stats = os.stat(filepath)
- post_data = {
- 'S-File-MD5': md5_hash.hexdigest(),
- 'Check': 1,
- 'Offset': 0,
- 'Uuid': uuid.uuid4(),
- 'TotalSize': file_stats.st_size,
- }
- url = 'http://{ip}:3030/uploadFile/upload'.format(ip=printer_ip)
- num_parts = (int)(file_stats.st_size / part_size)
- logger.debug("Uploaded file will be split into {} parts", num_parts)
- i = 0
- while i <= num_parts:
- offset = i * part_size
- uploadProgress = round(i / num_parts * 100)
- with open(filepath, 'rb') as f:
- f.seek(offset)
- file_part = f.read(part_size)
- logger.debug("Uploading part {}/{} (offset: {})",
- i, num_parts, offset)
- if not upload_file_part(url, post_data, filename, file_part, offset):
- logger.error("Uploading file to printer failed.")
- break
- logger.debug("Part {}/{} uploaded.", i, num_parts, offset)
- i += 1
- uploadProgress = 100
- os.remove(filepath)
- return True
-
-
-def upload_file_part(url, post_data, file_name, file_part, offset):
- post_data['Offset'] = offset
- post_files = {'File': (file_name, file_part)}
- response = requests.post(url, data=post_data, files=post_files)
- status = json.loads(response.text)
- if status['success']:
- return True
- logger.error(json.loads(response.text))
- return False
-
-
-@socketio.on('connect')
-def sio_handle_connect(auth):
- logger.info('Client connected')
- socketio.emit('printers', printers)
-
-
-@socketio.on('disconnect')
-def sio_handle_disconnect():
- logger.info('Client disconnected')
-
-
-@socketio.on('printers')
-def sio_handle_printers(data):
- logger.debug('client.printers >> '+data)
- main()
-
-
-@socketio.on('printer_info')
-def sio_handle_printer_status(data):
- logger.debug('client.printer_info >> '+data['id'])
- get_printer_status(data['id'])
- get_printer_attributes(data['id'])
-
-
-@socketio.on('printer_files')
-def sio_handle_printer_files(data):
- logger.debug('client.printer_files >> '+json.dumps(data))
- get_printer_files(data['id'], data['url'])
-
-
-@socketio.on('action_delete')
-def sio_handle_action_delete(data):
- logger.debug('client.action_delete >> '+json.dumps(data))
- send_printer_cmd(data['id'], 259, {"FileList": [data['data']]})
-
-
-@socketio.on('action_print')
-def sio_handle_action_print(data):
- logger.debug('client.action_print >> '+json.dumps(data))
- send_printer_cmd(data['id'], 128, {
- "Filename": data['data'], "StartLayer": 0})
-
-
-def get_printer_status(id):
- send_printer_cmd(id, 0)
-
-
-def get_printer_attributes(id):
- send_printer_cmd(id, 1)
-
-
-def get_printer_files(id, url):
- send_printer_cmd(id, 258, {"Url": url})
-
-
-def send_printer_cmd(id, cmd, data={}):
- printer = printers[id]
- ts = int(time.time())
- payload = {
- "Id": printer['connection'],
- "Data": {
- "Cmd": cmd,
- "Data": data,
- "RequestID": os.urandom(8).hex(),
- "MainboardID": id,
- "TimeStamp": ts,
- "From": 0
- },
- "Topic": "sdcp/request/" + id
+import typer
+import time
+from pathlib import Path
+from loguru import logger
+from rich.console import Console
+from rich.panel import Panel
+from rich.text import Text
+from livereload import Server
+
+# Database & migration helpers
+from app.database.db_config import setup_db_engine, get_db_session, close_db_session
+from app.database.db_migration import run_migrations, get_current_version
+from app.database.db_backup import DatabaseBackup
+from app.database.db_cli import app as db_app
+
+# CLI app
+app = typer.Typer(
+ name="ChitUI",
+ help="Web UI for Chitubox SDCP 3.0 resin printers",
+ add_completion=False,
+)
+app.add_typer(db_app, name="db", help="Database management commands")
+
+console = Console()
+
+def load_config(config_path: str | None = None) -> dict:
+ """Load configuration from YAML file or environment."""
+ import yaml
+
+ cfg = {
+ "host": os.environ.get("HOST", "0.0.0.0"),
+ "port": int(os.environ.get("PORT", 54780)),
+ "debug": os.environ.get("DEBUG", "false").lower() == "true",
+ "log_level": os.environ.get("LOG_LEVEL", "INFO"),
+ "upload_folder": os.environ.get("UPLOAD_FOLDER", "uploads"),
+ "log_folder": os.environ.get("LOG_FOLDER", "logs"),
+ "config_folder": os.environ.get("CONFIG_FOLDER", "config"),
+ "backup_folder": os.environ.get("BACKUP_FOLDER", "backups"),
+ "discovery_timeout": int(os.environ.get("DISCOVERY_TIMEOUT", 1)),
+ "admin_user": os.environ.get("ADMIN_USER", "admin"),
+ "admin_password": os.environ.get("ADMIN_PASSWORD", "admin"),
+ "secret_key": os.environ.get("SECRET_KEY", os.urandom(24).hex()),
+ "database_uri": os.environ.get("DATABASE_URI", "sqlite:///chitui.db"),
+ "db_pool_size": int(os.environ.get("DB_POOL_SIZE", 10)),
+ "db_max_overflow": int(os.environ.get("DB_MAX_OVERFLOW", 20)),
+ "db_pool_timeout": int(os.environ.get("DB_POOL_TIMEOUT", 30)),
+ "auto_backup_enabled": os.environ.get("AUTO_BACKUP_ENABLED", "true").lower() == "true",
+ "auto_backup_interval": int(os.environ.get("AUTO_BACKUP_INTERVAL", 24)),
+ "auto_backup_keep": int(os.environ.get("AUTO_BACKUP_KEEP", 7)),
}
- logger.debug("printer << \n{p}", p=json.dumps(payload, indent=4))
- if id in websockets:
- websockets[id].send(json.dumps(payload))
-
-def discover_printers():
- logger.info("Starting printer discovery.")
- msg = b'M99999'
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,
- socket.IPPROTO_UDP) # UDP
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
- sock.settimeout(discovery_timeout)
- sock.bind(('', 54781))
- sock.sendto(msg, ("255.255.255.255", 3000))
- socketOpen = True
- printers = None
- while (socketOpen):
+ if config_path:
try:
- data = sock.recv(8192)
- printers = save_discovered_printer(data)
- except TimeoutError:
- sock.close()
- break
- logger.info("Discovery done.")
- return printers
-
-
-def save_discovered_printer(data):
- j = json.loads(data.decode('utf-8'))
- printer = {}
- printer['connection'] = j['Id']
- printer['name'] = j['Data']['Name']
- printer['model'] = j['Data']['MachineName']
- printer['brand'] = j['Data']['BrandName']
- printer['ip'] = j['Data']['MainboardIP']
- printer['protocol'] = j['Data']['ProtocolVersion']
- printer['firmware'] = j['Data']['FirmwareVersion']
- printers[j['Data']['MainboardID']] = printer
- logger.info("Discovered: {n} ({i})".format(
- n=printer['name'], i=printer['ip']))
- return printers
-
-
-def connect_printers(printers):
- for id, printer in printers.items():
- url = "ws://{ip}:3030/websocket".format(ip=printer['ip'])
- logger.info("Connecting to: {n}".format(n=printer['name']))
- websocket.setdefaulttimeout(1)
- ws = websocket.WebSocketApp(url,
- on_message=ws_msg_handler,
- on_open=lambda _: ws_connected_handler(
- printer['name']),
- on_close=lambda _, s, m: logger.info(
- "Connection to '{n}' closed: {m} ({s})".format(n=printer['name'], m=m, s=s)),
- on_error=lambda _, e: logger.info(
- "Connection to '{n}' error: {e}".format(n=printer['name'], e=e))
- )
- websockets[id] = ws
- Thread(target=lambda: ws.run_forever(reconnect=1), daemon=True).start()
-
- return True
-
-
-def ws_connected_handler(name):
- logger.info("Connected to: {n}".format(n=name))
- socketio.emit('printers', printers)
-
-
-def ws_msg_handler(ws, msg):
- data = json.loads(msg)
- logger.debug("printer >> \n{m}", m=json.dumps(data, indent=4))
- if data['Topic'].startswith("sdcp/response/"):
- socketio.emit('printer_response', data)
- elif data['Topic'].startswith("sdcp/status/"):
- socketio.emit('printer_status', data)
- elif data['Topic'].startswith("sdcp/attributes/"):
- socketio.emit('printer_attributes', data)
- elif data['Topic'].startswith("sdcp/error/"):
- socketio.emit('printer_error', data)
- elif data['Topic'].startswith("sdcp/notice/"):
- socketio.emit('printer_notice', data)
- else:
- logger.warning("--- UNKNOWN MESSAGE ---")
- logger.warning(data)
- logger.warning("--- UNKNOWN MESSAGE ---")
-
-
-def main():
- printers = discover_printers()
- if printers:
- connect_printers(printers)
- socketio.emit('printers', printers)
+ with open(config_path, "r") as f:
+ user_cfg = yaml.safe_load(f)
+ if isinstance(user_cfg, dict):
+ cfg.update(user_cfg)
+ except Exception as e:
+ logger.warning(f"Failed to load config from {config_path}: {e}")
+
+ return cfg
+
+def setup_logging(config: dict):
+ """Initialize loguru logging to stdout and file."""
+ logger.remove()
+ Path(config["log_folder"]).mkdir(parents=True, exist_ok=True)
+
+ logger.add(
+ sys.stderr,
+ level=config["log_level"],
+ format="{time:YYYY-MM-DD HH:mm:ss} | "
+ "{level: <8} | "
+ "{name}:{function}:{line} - "
+ "{message}",
+ colorize=True,
+ )
+ logger.add(
+ Path(config["log_folder"]) / "chitui.log",
+ rotation="10 MB",
+ retention="1 week",
+ level=config["log_level"],
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
+ )
+
+def print_banner(config: dict):
+ """Render Rich ASCII banner with startup info."""
+ banner = Text()
+ banner.append(" _____ _ _ _ _ _ ___ \n", style="blue")
+ banner.append(" / ____| | (_) | | | |_|__ \\\n", style="blue")
+ banner.append("| | | |__ _| |_| | | | | |\n", style="cyan")
+ banner.append("| | | '_ \\| | __| | | | | |\n", style="cyan")
+ banner.append("| |____| | | | | |_| |_| |_| |\n", style="green")
+ banner.append(" \\_____|_| |_|_|\\__|\\___/\\___/\n", style="green")
+
+ sub = Text()
+ sub.append("Web UI for Chitubox SDCP 3.0 resin printers\n\n", style="yellow")
+ sub.append("Server running at: ", style="white")
+ sub.append(f"http://{config['host']}:{config['port']}\n", style="cyan bold")
+ sub.append("Debug mode: ", style="white")
+ sub.append("Enabled\n" if config["debug"] else "Disabled\n",
+ style="yellow bold" if config["debug"] else "green bold")
+
+ # DB info
+ db_uri = config["database_uri"]
+ db_type = ("SQLite" if "sqlite" in db_uri else
+ "MySQL" if "mysql" in db_uri else
+ "PostgreSQL" if "postgresql" in db_uri else
+ "Unknown")
+ sub.append("Database: ", style="white")
+ sub.append(f"{db_type}\n", style="cyan bold")
+
+ engine, _ = setup_db_engine(config)
+ version = get_current_version(engine)
+ sub.append("DB version: ", style="white")
+ sub.append(f"{version}\n", style="cyan bold")
+
+ console.print(Panel(Text.assemble(banner, "\n", sub),
+ title="ChitUI", subtitle="v1.0.0", border_style="blue"))
+
+def setup_auto_backup(config: dict):
+ """Spawn background thread for periodic backups."""
+ if not config["auto_backup_enabled"]:
+ return
+
+ from threading import Thread
+ def worker():
+ interval = config["auto_backup_interval"] * 3600
+ keep = config["auto_backup_keep"]
+ while True:
+ time.sleep(interval)
+ try:
+ mgr = DatabaseBackup(config)
+ info = mgr.create_backup("Automatic backup")
+ if info:
+ logger.info(f"Backup done: {info['filename']}")
+ all_bk = mgr.list_backups()
+ for old in all_bk[keep:]:
+ mgr.delete_backup(old["id"])
+ logger.info(f"Removed old backup {old['filename']}")
+ else:
+ logger.error("Backup failed")
+ except Exception as e:
+ logger.error(f"Backup error: {e}")
+
+ Thread(target=worker, daemon=True).start()
+ logger.info("Auto-backup thread started")
+
+@app.command()
+def run(
+ config_file: str = typer.Option(None, "--config", "-c"),
+ host: str = typer.Option(None, "--host", "-h"),
+ port: int = typer.Option(None, "--port", "-p"),
+ debug: bool = typer.Option(None, "--debug", "-d"),
+ log_level: str = typer.Option(None, "--log-level", "-l"),
+ database_uri: str= typer.Option(None, "--db", "--database"),
+ auto_backup: bool= typer.Option(None, "--auto-backup/--no-auto-backup")
+):
+ """Launch the ChitUI server (with optional Livereload in debug)."""
+ cfg = load_config(config_file)
+ # CLI overrides
+ for k,v in [("host",host),("port",port),("debug",debug),
+ ("log_level",log_level),("database_uri",database_uri),
+ ("auto_backup_enabled",auto_backup)]:
+ if v is not None:
+ cfg[k] = v
+
+ setup_logging(cfg)
+ Path(cfg["upload_folder"]).mkdir(exist_ok=True)
+ Path(cfg["config_folder"]).mkdir(exist_ok=True)
+ Path(cfg["backup_folder"]).mkdir(exist_ok=True)
+
+ engine, Session = setup_db_engine(cfg,
+ pool_size=cfg["db_pool_size"],
+ max_overflow=cfg["db_max_overflow"],
+ timeout=cfg["db_pool_timeout"],
+ )
+
+ try:
+ run_migrations()
+ logger.info("Migrations applied")
+ except Exception as e:
+ logger.error(f"Migration error: {e}")
+
+ print_banner(cfg)
+ setup_auto_backup(cfg)
+
+ from app import create_app
+ flask_app, socketio = create_app(cfg)
+
+ # Run
+ if cfg["debug"]:
+ server = Server(flask_app.wsgi_app)
+ server.watch("app/**/*.py")
+ server.watch("app/templates/**/*.html")
+ server.watch("app/static/**/*.*")
+ server.serve(
+ host=cfg["host"],
+ port=cfg["port"],
+ liveport=35729,
+ debug=True,
+ open_url_delay=1
+ )
else:
- logger.error("No printers discovered.")
-
+ socketio.run(
+ flask_app,
+ host=cfg["host"],
+ port=cfg["port"],
+ debug=False,
+ use_reloader=False,
+ log_output=True
+ )
if __name__ == "__main__":
- main()
-
- socketio.run(app, host='0.0.0.0', port=port,
- debug=debug, use_reloader=debug, log_output=True)
+ app()
diff --git a/migration/versions/initial_migration.py b/migration/versions/initial_migration.py
new file mode 100644
index 0000000..193103b
--- /dev/null
+++ b/migration/versions/initial_migration.py
@@ -0,0 +1,85 @@
+"""
+Initial database migration for ChitUI
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from datetime import datetime
+
+# revision identifiers
+revision = '54a6ec76ddc1'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Create users table
+ op.create_table(
+ 'users',
+ sa.Column('id', sa.String(36), primary_key=True),
+ sa.Column('username', sa.String(80), unique=True, nullable=False),
+ sa.Column('password_hash', sa.String(256), nullable=False),
+ sa.Column('role', sa.String(20), default='user'),
+ sa.Column('created_at', sa.DateTime, default=datetime.utcnow),
+ sa.Column('last_login', sa.DateTime)
+ )
+
+ # Create printers table
+ op.create_table(
+ 'printers',
+ sa.Column('id', sa.String(36), primary_key=True),
+ sa.Column('name', sa.String(80), nullable=False),
+ sa.Column('ip_address', sa.String(15), nullable=False),
+ sa.Column('model', sa.String(80)),
+ sa.Column('brand', sa.String(80)),
+ sa.Column('connection_id', sa.String(80)),
+ sa.Column('firmware', sa.String(80)),
+ sa.Column('protocol', sa.String(80)),
+ sa.Column('last_seen', sa.DateTime),
+ sa.Column('created_at', sa.DateTime, default=datetime.utcnow),
+ sa.Column('updated_at', sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow),
+ sa.Column('settings', sa.Text),
+ sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'))
+ )
+
+ # Create print_jobs table
+ op.create_table(
+ 'print_jobs',
+ sa.Column('id', sa.String(36), primary_key=True),
+ sa.Column('filename', sa.String(255), nullable=False),
+ sa.Column('start_time', sa.DateTime, default=datetime.utcnow),
+ sa.Column('end_time', sa.DateTime),
+ sa.Column('duration', sa.Integer),
+ sa.Column('status', sa.String(20), default='started'),
+ sa.Column('layers', sa.Integer),
+ sa.Column('completed_layers', sa.Integer, default=0),
+ sa.Column('settings', sa.Text),
+ sa.Column('error_message', sa.Text),
+ sa.Column('printer_id', sa.String(36), sa.ForeignKey('printers.id'), nullable=False),
+ sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False)
+ )
+
+ # Create system_settings table
+ op.create_table(
+ 'system_settings',
+ sa.Column('key', sa.String(80), primary_key=True),
+ sa.Column('value', sa.Text),
+ sa.Column('type', sa.String(20), default='string'),
+ sa.Column('created_at', sa.DateTime, default=datetime.utcnow),
+ sa.Column('updated_at', sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+ )
+
+ # Create indexes
+ op.create_index('idx_printers_user_id', 'printers', ['user_id'])
+ op.create_index('idx_print_jobs_printer_id', 'print_jobs', ['printer_id'])
+ op.create_index('idx_print_jobs_user_id', 'print_jobs', ['user_id'])
+ op.create_index('idx_print_jobs_status', 'print_jobs', ['status'])
+
+
+def downgrade():
+ # Drop tables in reverse order
+ op.drop_table('print_jobs')
+ op.drop_table('printers')
+ op.drop_table('system_settings')
+ op.drop_table('users')
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index c0c13ee..babe599 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,68 @@
-flask==3.0.3
-flask-socketio==5.4.1
-websocket-client==1.8.0
-requests==2.32.3
+# Web Framework and Extensions
+flask==2.3.3
+flask-socketio==5.3.6
+flask-login==0.6.3
+flask-wtf==1.2.1
+flask-swagger-ui==4.11.1
+flask-sqlalchemy==3.1.1
+flask-migrate==4.0.5
+gevent==24.2.1
+gevent-websocket==0.10.1
+
+# API and Documentation
+pyyaml==6.0.1
+marshmallow==3.20.1
+apispec==6.3.0
+
+# WebSocket and Networking
+python-socketio==5.10.0
+websocket-client==1.6.4
+requests==2.31.0
+
+# Database
+sqlalchemy==2.0.27
+alembic==1.13.1
+pymysql==1.1.0 # For MySQL support
+psycopg2-binary==2.9.9 # For PostgreSQL support
+
+# CLI and Console
+typer[all]==0.9.0
+rich==13.7.0
+tqdm==4.66.1
loguru==0.7.2
+
+# Utilities
+python-dotenv==1.0.0
+werkzeug==2.3.7
+gunicorn==21.2.0
+
+SQLAlchemy>=2.0.0
+alembic>=1.4.3
+PyMySQL>=1.0.2
+psycopg2-binary>=2.9.1
+
+pygments>=2.10.0
+
+backoff>=1.11.1
+tenacity>=8.0.1
+
+passlib
+Pillow
+numpy
+shapely
+trimesh
+
+apscheduler
+
+alive-progress==3.1.4
+scapy==2.5.0
+netifaces
+
+livereload
+flask
+flask-login
+loguru
+requests
+flasgger
+marshmallow
+flask-swagger-ui
\ No newline at end of file
diff --git a/web/assets/apple-touch-icon.png b/static/apple-touch-icon.png
similarity index 100%
rename from web/assets/apple-touch-icon.png
rename to static/apple-touch-icon.png
diff --git a/static/assets/apple-touch-icon.png b/static/assets/apple-touch-icon.png
new file mode 100644
index 0000000..70edbf4
Binary files /dev/null and b/static/assets/apple-touch-icon.png differ
diff --git a/web/assets/favicon-48x48.png b/static/assets/favicon-48x48.png
similarity index 100%
rename from web/assets/favicon-48x48.png
rename to static/assets/favicon-48x48.png
diff --git a/static/assets/favicon.ico b/static/assets/favicon.ico
new file mode 100644
index 0000000..ee071b4
Binary files /dev/null and b/static/assets/favicon.ico differ
diff --git a/web/assets/favicon.svg b/static/assets/favicon.svg
similarity index 100%
rename from web/assets/favicon.svg
rename to static/assets/favicon.svg
diff --git a/web/assets/web-app-manifest-192x192.png b/static/assets/web-app-manifest-192x192.png
similarity index 100%
rename from web/assets/web-app-manifest-192x192.png
rename to static/assets/web-app-manifest-192x192.png
diff --git a/web/assets/web-app-manifest-512x512.png b/static/assets/web-app-manifest-512x512.png
similarity index 100%
rename from web/assets/web-app-manifest-512x512.png
rename to static/assets/web-app-manifest-512x512.png
diff --git a/web/css/bootstrap-icons.min.css b/static/css/bootstrap-icons.min.css
similarity index 100%
rename from web/css/bootstrap-icons.min.css
rename to static/css/bootstrap-icons.min.css
diff --git a/web/css/bootstrap.min.css b/static/css/bootstrap.min.css
similarity index 100%
rename from web/css/bootstrap.min.css
rename to static/css/bootstrap.min.css
diff --git a/web/css/bootstrap.min.css.map b/static/css/bootstrap.min.css.map
similarity index 100%
rename from web/css/bootstrap.min.css.map
rename to static/css/bootstrap.min.css.map
diff --git a/static/css/chitui.css b/static/css/chitui.css
new file mode 100644
index 0000000..667498b
--- /dev/null
+++ b/static/css/chitui.css
@@ -0,0 +1,134 @@
+/**
+ * ChitUI - Custom CSS styles
+ */
+
+/* Main layout */
+html, body {
+ height: 100%;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+main {
+ flex: 1;
+ min-height: calc(100vh - 56px);
+}
+
+/* Printer list sidebar */
+.scrollarea {
+ overflow-y: auto;
+ max-height: calc(100vh - 56px - 60px);
+}
+
+.printerListItem:hover {
+ background-color: var(--bs-tertiary-bg);
+}
+
+.printerListItem.active {
+ background-color: var(--bs-tertiary-bg);
+ border-left: 4px solid var(--bs-primary);
+}
+
+/* Button styles */
+.btn-bd-primary {
+ --bs-btn-color: var(--bs-body-bg);
+ --bs-btn-bg: var(--bs-primary);
+ --bs-btn-border-color: var(--bs-primary);
+ --bs-btn-hover-color: var(--bs-body-bg);
+ --bs-btn-hover-bg: var(--bs-primary-emphasis);
+ --bs-btn-hover-border-color: var(--bs-primary-emphasis);
+ --bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
+ --bs-btn-active-color: var(--bs-body-bg);
+ --bs-btn-active-bg: var(--bs-primary-emphasis);
+ --bs-btn-active-border-color: var(--bs-primary-emphasis);
+}
+
+/* Card styles */
+.card {
+ --bs-card-cap-bg: var(--bs-tertiary-bg);
+}
+
+/* Tab content */
+.tab-content {
+ min-height: 300px;
+}
+
+/* Custom table styles */
+.fieldKey {
+ width: 30%;
+ font-weight: bold;
+}
+
+/* Camera view */
+.camera-view {
+ min-height: 300px;
+ background-color: var(--bs-tertiary-bg);
+ border-radius: 0.25rem;
+}
+
+/* File options */
+.fileOption {
+ cursor: pointer;
+ opacity: 0.7;
+}
+
+.fileOption:hover {
+ opacity: 1;
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+ main {
+ flex-direction: column;
+ }
+
+ .scrollarea {
+ max-height: 300px;
+ }
+}
+
+/* Theme-specific styles */
+[data-bs-theme="dark"] .text-body-emphasis {
+ color: #f8f9fa !important;
+}
+
+[data-bs-theme="dark"] .text-body-secondary {
+ color: #adb5bd !important;
+}
+
+/* Progress bar animation */
+.progress-bar-animated {
+ animation: progress-bar-stripes 1s linear infinite;
+}
+
+/* Fix for modal z-index over toast */
+.modal {
+ z-index: 1060;
+}
+
+.toast-container {
+ z-index: 1070;
+}
+
+/* Custom scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bs-body-bg);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--bs-secondary);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--bs-primary);
+}
\ No newline at end of file
diff --git a/web/css/fonts/bootstrap-icons.woff b/static/css/fonts/bootstrap-icons.woff
similarity index 100%
rename from web/css/fonts/bootstrap-icons.woff
rename to static/css/fonts/bootstrap-icons.woff
diff --git a/web/css/fonts/bootstrap-icons.woff2 b/static/css/fonts/bootstrap-icons.woff2
similarity index 100%
rename from web/css/fonts/bootstrap-icons.woff2
rename to static/css/fonts/bootstrap-icons.woff2
diff --git a/static/favicon-48x48.png b/static/favicon-48x48.png
new file mode 100644
index 0000000..129b7a5
Binary files /dev/null and b/static/favicon-48x48.png differ
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000..ee071b4
Binary files /dev/null and b/static/favicon.ico differ
diff --git a/static/favicon.svg b/static/favicon.svg
new file mode 100644
index 0000000..4526e91
--- /dev/null
+++ b/static/favicon.svg
@@ -0,0 +1,23 @@
+
\ No newline at end of file
diff --git a/static/img/apple-touch-icon.png b/static/img/apple-touch-icon.png
new file mode 100644
index 0000000..70edbf4
Binary files /dev/null and b/static/img/apple-touch-icon.png differ
diff --git a/static/img/default_printer.png b/static/img/default_printer.png
new file mode 100644
index 0000000..8e988eb
Binary files /dev/null and b/static/img/default_printer.png differ
diff --git a/web/img/elegoo_saturn4ultra.webp b/static/img/elegoo_saturn4ultra.webp
similarity index 100%
rename from web/img/elegoo_saturn4ultra.webp
rename to static/img/elegoo_saturn4ultra.webp
diff --git a/static/img/favicon-48x48.png b/static/img/favicon-48x48.png
new file mode 100644
index 0000000..129b7a5
Binary files /dev/null and b/static/img/favicon-48x48.png differ
diff --git a/static/img/favicon.ico b/static/img/favicon.ico
new file mode 100644
index 0000000..ee071b4
Binary files /dev/null and b/static/img/favicon.ico differ
diff --git a/static/img/favicon.svg b/static/img/favicon.svg
new file mode 100644
index 0000000..4526e91
--- /dev/null
+++ b/static/img/favicon.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/static/js/admin.js b/static/js/admin.js
new file mode 100644
index 0000000..86d9d5b
--- /dev/null
+++ b/static/js/admin.js
@@ -0,0 +1,143 @@
+// static/js/admin.js
+
+// Elements
+const uptimeEl = document.getElementById("uptime");
+const printersEl = document.getElementById("connectedPrinters");
+const refreshLogsBtn = document.getElementById("refreshLogs");
+const clearLogsBtn = document.getElementById("clearLogs");
+const logContentEl = document.getElementById("logContent");
+const logLevelSelect = document.getElementById("logLevel");
+
+let lastHealth = null;
+
+/**
+ * Fetch health (uptime) and printer count.
+ */
+async function fetchStatus() {
+ try {
+ const [healthResp, printersResp] = await Promise.all([
+ fetch("/api/health").then(r => r.json()),
+ fetch("/api/printer/list").then(r => r.json())
+ ]);
+
+ if (healthResp.success) {
+ lastHealth = healthResp.data.uptime; // total seconds since boot
+ }
+ if (printersResp.success) {
+ printersEl.textContent = Object.keys(printersResp.data).length;
+ }
+ } catch (err) {
+ console.error("Error fetching status:", err);
+ lastHealth = null;
+ printersEl.textContent = "Error";
+ }
+}
+
+/**
+ * Render the uptime (with seconds) from lastHealth.
+ */
+function renderUptime() {
+ if (typeof lastHealth !== "number") {
+ uptimeEl.textContent = "Error";
+ return;
+ }
+ // add the number of seconds elapsed since we fetched lastHealth
+ const now = Math.floor((Date.now() / 1000));
+ const elapsed = lastHealth + (now - Math.floor(lastHealthFetchTime));
+ // But to keep it simpler: just use lastHealth and tick upwards every second.
+ const secs = lastHealth + (Math.floor((Date.now() - lastHealthFetchTimeMs) / 1000));
+ const days = Math.floor(secs / 86400);
+ const hours = Math.floor((secs % 86400) / 3600);
+ const mins = Math.floor((secs % 3600) / 60);
+ const seconds = secs % 60;
+ uptimeEl.textContent = `${days}d ${hours}h ${mins}m ${seconds}s`;
+}
+
+// Weβll store the wallβclock time when we fetched lastHealth
+let lastHealthFetchTime = 0;
+let lastHealthFetchTimeMs = 0;
+
+/**
+ * Fetch & render right away.
+ */
+async function refreshStatus() {
+ await fetchStatus();
+ lastHealthFetchTime = Math.floor(Date.now() / 1000) - lastHealth;
+ lastHealthFetchTimeMs = Date.now() - (lastHealth * 1000);
+ renderUptime();
+}
+
+// Logs code unchangedβ¦
+async function refreshLogs() {
+ logContentEl.textContent = "Loading logsβ¦";
+ try {
+ const resp = await fetch("/api/logs");
+ const json = await resp.json();
+ if (!json.success || !json.data?.lines) {
+ logContentEl.textContent = "Failed to load logs.";
+ return;
+ }
+ const level = logLevelSelect.value;
+ const lines = json.data.lines;
+ const filtered = level === "all"
+ ? lines
+ : lines.filter(l => l.toLowerCase().includes(level));
+ logContentEl.textContent = filtered.length
+ ? filtered.join("")
+ : `No ${level.toUpperCase()} entries.`;
+ } catch (err) {
+ console.error("Error fetching logs:", err);
+ logContentEl.textContent = "Error fetching logs.";
+ }
+}
+
+function clearLogs() {
+ logContentEl.textContent = "";
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ // initial fetch
+ refreshStatus();
+ refreshLogs();
+
+ // re-fetch health & printers every 30s
+ setInterval(refreshStatus, 30_000);
+
+ // but tick the uptime display every second
+ setInterval(renderUptime, 1000);
+
+ // logs UI
+ refreshLogsBtn.addEventListener("click", refreshLogs);
+ clearLogsBtn.addEventListener("click", clearLogs);
+ logLevelSelect.addEventListener("change", refreshLogs);
+});
+
+
+// RestartβServer button
+const restartBtn = document.getElementById('restartServer');
+if (restartBtn) {
+ restartBtn.addEventListener('click', async (e) => {
+ e.preventDefault();
+ if (!confirm('Really restart the server? All connections will drop.')) return;
+
+ restartBtn.disabled = true;
+ restartBtn.innerHTML = ' Restartingβ¦';
+
+ try {
+ const resp = await fetch('/admin/restart', { method: 'POST' });
+ const json = await resp.json();
+ if (json.success) {
+ alert(json.message);
+ } else {
+ alert('Failed to restart: ' + (json.error?.message || resp.statusText));
+ restartBtn.disabled = false;
+ restartBtn.textContent = 'Restart Server';
+ }
+ } catch (err) {
+ console.error(err);
+ alert('Error communicating with server.');
+ restartBtn.disabled = false;
+ restartBtn.textContent = 'Restart Server';
+ }
+ });
+}
diff --git a/web/js/bootstrap.bundle.min.js b/static/js/bootstrap.bundle.min.js
similarity index 100%
rename from web/js/bootstrap.bundle.min.js
rename to static/js/bootstrap.bundle.min.js
diff --git a/web/js/bootstrap.bundle.min.js.map b/static/js/bootstrap.bundle.min.js.map
similarity index 100%
rename from web/js/bootstrap.bundle.min.js.map
rename to static/js/bootstrap.bundle.min.js.map
diff --git a/static/js/chitui.js b/static/js/chitui.js
new file mode 100644
index 0000000..d0f5d19
--- /dev/null
+++ b/static/js/chitui.js
@@ -0,0 +1,2311 @@
+/**
+ * ChitUI - Frontend JavaScript
+ * Handles WebSocket communication with the server and UI interactions
+ */
+
+// Global state
+let socket = null;
+let currentPrinter = null;
+let printers = {};
+let uploadTasks = {};
+let toastUpload = null;
+let confirmModal = null;
+let addPrinterModal = null;
+let cameraInterval = null;
+let cameraStreaming = false;
+
+// DOM Ready
+document.addEventListener('DOMContentLoaded', () => {
+ initSocketIO();
+ setupEventListeners();
+
+ // Initialize Bootstrap components
+ toastUpload = new bootstrap.Toast(document.getElementById('toastUpload'));
+ confirmModal = new bootstrap.Modal(document.getElementById('modalConfirm'));
+ addPrinterModal = new bootstrap.Modal(document.getElementById('modalAddPrinter'));
+
+ // Initialize tooltips
+ const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
+ [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
+});
+
+/**
+ * Initialize Socket.IO connection
+ */
+function initSocketIO() {
+ socket = io();
+
+ // Connection events
+ socket.on('connect', () => {
+ console.log('Connected to server');
+ setServerStatus(true);
+ });
+
+ socket.on('disconnect', () => {
+ console.log('Disconnected from server');
+ setServerStatus(false);
+ });
+
+ // Data events
+ socket.on('printers', handlePrinters);
+ socket.on('printer_status', handlePrinterStatus);
+ socket.on('printer_attributes', handlePrinterAttributes);
+ socket.on('printer_response', handlePrinterResponse);
+ socket.on('printer_error', handlePrinterError);
+ socket.on('printer_notice', handlePrinterNotice);
+ socket.on('upload_progress', handleUploadProgress);
+}
+
+/**
+ * Set up event listeners for UI elements
+ */
+function setupEventListeners() {
+ // Discover button
+ document.getElementById('btnScanLAN').addEventListener('click', discoverPrinters);
+
+ // Upload button
+ document.getElementById('btnUpload').addEventListener('click', handleUploadClick);
+
+ // Confirm button (in modal)
+ document.getElementById('btnConfirm').addEventListener('click', handleConfirmAction);
+
+ // Add printer button
+ document.getElementById('btnAddPrinter').addEventListener('click', () => {
+ addPrinterModal.show();
+ });
+
+ // Add printer form
+ document.getElementById('formAddPrinter').addEventListener('submit', handleAddPrinter);
+
+ // Server status icon click (alternative for discover)
+ document.querySelector('.serverStatus').addEventListener('click', () => {
+ discoverPrinters();
+ });
+
+ // Server status icon hover
+ document.querySelector('.serverStatus').addEventListener('mouseenter', (e) => {
+ if (e.target.classList.contains('bi-cloud-check-fill')) {
+ e.target.classList.remove('bi-cloud-check-fill');
+ e.target.classList.add('bi-cloud-plus', 'text-primary');
+ }
+ });
+
+ document.querySelector('.serverStatus').addEventListener('mouseleave', (e) => {
+ if (e.target.classList.contains('bi-cloud-plus')) {
+ e.target.classList.remove('bi-cloud-plus', 'text-primary');
+ e.target.classList.add('bi-cloud-check-fill');
+ }
+ });
+}
+
+/**
+ * Send discovery request to server
+ */
+function discoverPrinters() {
+ socket.emit('printers');
+ showToast('Discovering printers...', 'info');
+}
+
+/**
+ * Handle printers data received from server
+ */
+function handlePrinters(data) {
+ printers = data;
+ updatePrintersList();
+
+ // Update current printer details if one is selected
+ if (currentPrinter && printers[currentPrinter]) {
+ displayPrinterDetails(currentPrinter);
+ }
+
+ // Update upload count
+ updateUploadCount();
+}
+
+/**
+ * Update the printers list in the sidebar
+ */
+function updatePrintersList() {
+ const printersList = document.getElementById('printersList');
+ printersList.innerHTML = '';
+
+ if (Object.keys(printers).length === 0) {
+ printersList.innerHTML = 'No printers found
';
+ return;
+ }
+
+ const template = document.getElementById('tmplPrintersListItem');
+
+ Object.entries(printers).forEach(([id, printer]) => {
+ const item = template.content.cloneNode(true);
+
+ // Set printer data
+ const printerItem = item.querySelector('.printerListItem');
+ printerItem.dataset.printerId = id;
+
+ // Add event listener for selection
+ printerItem.addEventListener('click', (e) => {
+ // Prevent click from triggering on delete button
+ if (e.target.closest('.btn-delete-printer')) {
+ return;
+ }
+ e.preventDefault();
+ selectPrinter(id);
+ });
+
+ // Set printer icon with fallback
+ const iconImg = item.querySelector('.printerIcon');
+ iconImg.src = printer.icon || '/static/img/default_printer.png';
+ iconImg.onerror = function () {
+ this.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgZmlsbD0iI2RlZSIgdmlld0JveD0iMCAwIDE2IDE2Ij48cGF0aCBkPSJNMi41IDhBMi41IDIuNSAwIDEgMSA1IDUuNSAyLjUgMi41IDAgMCAxIDIuNSA4em0wIDBhMi41IDIuNSAwIDEgMSA1IDAgMi41IDIuNSAwIDAgMS01IDB6Ii8+PC9zdmc+';
+ };
+ iconImg.alt = printer.name;
+
+ // Set printer information
+ item.querySelector('.printerName').textContent = printer.name;
+ item.querySelector('.printerType').textContent = `${printer.brand} ${printer.model}`;
+ item.querySelector('.printerInfo').textContent = printer.ip || 'N/A';
+
+ // Set printer status icon
+ const statusIcon = item.querySelector('.printerStatus i');
+ if (printer.status === 'connected') {
+ statusIcon.className = 'bi bi-circle-fill text-success';
+ } else {
+ statusIcon.className = 'bi bi-circle-fill text-danger';
+ }
+
+ // Add delete button
+ const headerDiv = item.querySelector('.d-flex.flex-row.align-items-center.justify-content-between');
+ const deleteBtn = document.createElement('button');
+ deleteBtn.className = 'btn btn-sm btn-outline-danger ms-2 btn-delete-printer';
+ deleteBtn.innerHTML = '';
+ deleteBtn.title = "Remove printer";
+ deleteBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ confirmDeletePrinter(id, printer.name);
+ });
+ headerDiv.appendChild(deleteBtn);
+
+ printersList.appendChild(item);
+ });
+}
+
+// Add function to confirm printer deletion
+function confirmDeletePrinter(printerId, printerName) {
+ showConfirmModal('delete-printer', printerName);
+
+ // Update the confirm button to handle printer deletion
+ const confirmBtn = document.getElementById('btnConfirm');
+ confirmBtn.dataset.printerId = printerId;
+
+ // Override the existing handler temporarily
+ const originalHandler = confirmBtn.onclick;
+ confirmBtn.onclick = function() {
+ if (confirmBtn.dataset.action === 'delete-printer') {
+ deletePrinter(printerId);
+ } else {
+ // Call original handler for other actions
+ if (originalHandler) originalHandler.call(this);
+ }
+ };
+}
+
+// Add function to delete a printer
+function deletePrinter(printerId) {
+ // Close the modal first
+ if (confirmModal) confirmModal.hide();
+
+ // Call API to delete printer
+ fetch(`/api/printer/${printerId}/remove`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ // Remove from local cache
+ delete printers[printerId];
+
+ // Update UI
+ updatePrintersList();
+
+ // Reset current printer if it was the deleted one
+ if (currentPrinter === printerId) {
+ currentPrinter = null;
+ document.getElementById('printerName').textContent = 'Select a printer';
+ document.getElementById('printerType').textContent = '';
+ document.getElementById('printerStatus').textContent = '';
+ document.getElementById('navTabs').innerHTML = '';
+ document.getElementById('navPanes').innerHTML = '';
+ }
+
+ // Show success message
+ showToast(`Printer deleted successfully`, 'success');
+ } else {
+ showToast(data.error || 'Failed to delete printer', 'error');
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ showToast('Error deleting printer', 'error');
+ });
+}
+
+/**
+ * Select a printer and display its details
+ */
+function selectPrinter(printerId) {
+ // Highlight selected printer
+ document.querySelectorAll('.printerListItem').forEach(item => {
+ item.classList.remove('active');
+ });
+ document.querySelector(`.printerListItem[data-printer-id="${printerId}"]`)?.classList.add('active');
+
+ currentPrinter = printerId;
+ document.getElementById('uploadPrinter').value = printerId;
+
+ // Stop camera streaming if we were viewing a different printer
+ if (cameraStreaming) {
+ stopCameraStream();
+ }
+
+ displayPrinterDetails(printerId);
+
+ // Request updated information
+ socket.emit('printer_info', { id: printerId });
+ socket.emit('printer_files', { id: printerId, url: '/local' });
+}
+
+
+/**
+ * Create tabs for printer details
+ */
+function createTabs(printer) {
+ const navTabs = document.getElementById('navTabs');
+ const navPanes = document.getElementById('navPanes');
+
+ navTabs.innerHTML = '';
+ navPanes.innerHTML = '';
+
+ const tabTemplate = document.getElementById('tmplNavTab');
+ const paneTemplate = document.getElementById('tmplNavPane');
+
+ // 1. Status Tab
+ const statusTab = tabTemplate.content.cloneNode(true);
+ const statusTabBtn = statusTab.querySelector('button');
+ statusTabBtn.id = 'status-tab';
+ statusTabBtn.dataset.bsTarget = '#status-pane';
+ statusTabBtn.classList.add('active');
+ statusTabBtn.innerHTML = ' Status';
+ navTabs.appendChild(statusTab);
+
+ const statusPane = paneTemplate.content.cloneNode(true);
+ const statusPaneEl = statusPane.querySelector('.tab-pane');
+ statusPaneEl.id = 'status-pane';
+ statusPaneEl.classList.add('show', 'active');
+
+ const statusTableBody = statusPane.querySelector('tbody');
+ statusTableBody.id = 'status-table';
+ navPanes.appendChild(statusPane);
+
+ // 2. Files Tab
+ const filesTab = tabTemplate.content.cloneNode(true);
+ const filesTabBtn = filesTab.querySelector('button');
+ filesTabBtn.id = 'files-tab';
+ filesTabBtn.dataset.bsTarget = '#files-pane';
+ filesTabBtn.innerHTML = ' Files';
+ navTabs.appendChild(filesTab);
+
+ const filesPane = paneTemplate.content.cloneNode(true);
+ const filesPaneEl = filesPane.querySelector('.tab-pane');
+ filesPaneEl.id = 'files-pane';
+ filesPaneEl.innerHTML = `
+
+
+ `;
+ navPanes.appendChild(filesPane);
+
+ // Add event listeners for file refresh buttons
+ // filesPaneEl.querySelector('#btnRefreshLocalFiles').addEventListener('click', () => {
+ // socket.emit('printer_files', { id: printer.id, url: '/local' });
+ // filesPaneEl.querySelector('#files-list').innerHTML = `
+ //
+ //
+ //
Loading files...
+ //
+ // `;
+ // });
+
+ // filesPaneEl.querySelector('#btnRefreshUsbFiles').addEventListener('click', () => {
+ // socket.emit('printer_files', { id: printer.id, url: '/usb' });
+ // filesPaneEl.querySelector('#files-list').innerHTML = `
+ //
+ //
+ //
Loading USB files...
+ //
+ // `;
+ // });
+
+ // 3. Camera Tab (if supported)
+ if (printer.supports_camera) {
+ const cameraTab = tabTemplate.content.cloneNode(true);
+ const cameraTabBtn = cameraTab.querySelector('button');
+ cameraTabBtn.id = 'camera-tab';
+ cameraTabBtn.dataset.bsTarget = '#camera-pane';
+ cameraTabBtn.innerHTML = ' Camera';
+ navTabs.appendChild(cameraTab);
+
+ const cameraPane = paneTemplate.content.cloneNode(true);
+ const cameraPaneEl = cameraPane.querySelector('.tab-pane');
+ cameraPaneEl.id = 'camera-pane';
+ cameraPaneEl.innerHTML = `
+
+
Camera Stream
+
+
+
+
+
+
+
+
+
Click Start Stream to view camera feed
+
+
![Camera stream]()
+
+ `;
+ navPanes.appendChild(cameraPane);
+
+ // Add event listeners for camera controls
+ cameraPaneEl.querySelector('#btnStartStream').addEventListener('click', () => {
+ startCameraStream(printer.id);
+ });
+
+ cameraPaneEl.querySelector('#btnStopStream').addEventListener('click', () => {
+ stopCameraStream();
+ });
+
+ // When camera tab is clicked/shown, start the stream
+ cameraTabBtn.addEventListener('shown.bs.tab', () => {
+ if (!cameraStreaming) {
+ startCameraStream(printer.id);
+ }
+ });
+
+ // When another tab is clicked, stop the stream to save resources
+ cameraTabBtn.addEventListener('hidden.bs.tab', () => {
+ if (cameraStreaming) {
+ stopCameraStream();
+ }
+ });
+ }
+
+ // 4. Info Tab
+ const infoTab = tabTemplate.content.cloneNode(true);
+ const infoTabBtn = infoTab.querySelector('button');
+ infoTabBtn.id = 'info-tab';
+ infoTabBtn.dataset.bsTarget = '#info-pane';
+ infoTabBtn.innerHTML = ' Information';
+ navTabs.appendChild(infoTab);
+
+ const infoPane = paneTemplate.content.cloneNode(true);
+ const infoPaneEl = infoPane.querySelector('.tab-pane');
+ infoPaneEl.id = 'info-pane';
+
+ const infoTableBody = infoPane.querySelector('tbody');
+ infoTableBody.id = 'info-table';
+ navPanes.appendChild(infoPane);
+
+ // Populate tabs with data
+ populateStatusTab(printer);
+ populateInfoTab(printer);
+}
+
+/**
+ * Populate the status tab with printer information
+ */
+function populateStatusTab(printer) {
+ const statusTable = document.getElementById('status-table');
+ if (!statusTable) return;
+
+ statusTable.innerHTML = '';
+
+ const rows = [
+ { key: 'Status', value: formatStatus(printer.machine_status || 'Unknown') },
+ { key: 'Connection', value: formatConnectionStatus(printer.status) },
+ { key: 'IP Address', value: printer.ip || 'N/A' },
+ { key: 'Last Seen', value: printer.last_seen ? new Date(printer.last_seen * 1000).toLocaleString() : 'Never' }
+ ];
+
+ // Add print job information if printing
+ if (printer.machine_status === 'PRINTING') {
+ rows.push({ key: 'Print Status', value: formatPrintStatus(printer.print_status || 'Unknown') });
+
+ if (printer.current_file) {
+ rows.push({ key: 'Current File', value: printer.current_file });
+ }
+
+ if (printer.print_progress !== undefined) {
+ rows.push({
+ key: 'Progress',
+ value: `
+
+
${printer.print_progress}%
+
+ `
+ });
+ }
+
+ if (printer.remain_time) {
+ rows.push({ key: 'Remaining Time', value: formatTime(printer.remain_time) });
+ }
+
+ // Add print control buttons
+ rows.push({
+ key: 'Controls',
+ value: `
+
+
+
+
+ `
+ });
+ }
+
+ // Add rows to table
+ rows.forEach(row => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `
+ ${row.key} |
+ ${row.value} |
+ `;
+ statusTable.appendChild(tr);
+ });
+
+ // Add event listeners for print control buttons
+ const pauseBtn = document.getElementById('btnPausePrint');
+ if (pauseBtn) {
+ pauseBtn.addEventListener('click', () => {
+ socket.emit('action_pause', { id: printer.id });
+ });
+ }
+
+ const stopBtn = document.getElementById('btnStopPrint');
+ if (stopBtn) {
+ stopBtn.addEventListener('click', () => {
+ showConfirmModal('stop', 'the current print job');
+ });
+ }
+}
+
+/**
+ * Populate the info tab with printer details
+ */
+function populateInfoTab(printer) {
+ const infoTable = document.getElementById('info-table');
+ if (!infoTable) return;
+
+ infoTable.innerHTML = '';
+
+ const rows = [
+ { key: 'Name', value: printer.name },
+ { key: 'Model', value: printer.model },
+ { key: 'Brand', value: printer.brand },
+ { key: 'Firmware', value: printer.firmware || 'Unknown' },
+ { key: 'Protocol', value: printer.protocol || 'Unknown' }
+ ];
+
+ // Add resolution if available
+ if (printer.resolution) {
+ const resolution = Array.isArray(printer.resolution)
+ ? `${printer.resolution[0]} Γ ${printer.resolution[1]}`
+ : printer.resolution;
+
+ rows.push({ key: 'Resolution', value: resolution });
+ }
+
+ // Add build volume if available
+ if (printer.build_volume) {
+ const volume = Array.isArray(printer.build_volume)
+ ? `${printer.build_volume[0]} Γ ${printer.build_volume[1]} Γ ${printer.build_volume[2]} mm`
+ : printer.build_volume;
+
+ rows.push({ key: 'Build Volume', value: volume });
+ }
+
+ // Add camera support info
+ rows.push({
+ key: 'Camera Support',
+ value: printer.supports_camera
+ ? 'Yes'
+ : 'No'
+ });
+
+ // Add rows to table
+ rows.forEach(row => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `
+ ${row.key} |
+ ${row.value} |
+ `;
+ infoTable.appendChild(tr);
+ });
+
+ // Add rename button
+ const renameRow = document.createElement('tr');
+ renameRow.innerHTML = `
+
+
+ |
+ `;
+ infoTable.appendChild(renameRow);
+
+ // Add event listener for rename button
+ document.getElementById('btnRenamePrinter').addEventListener('click', () => {
+ const newName = prompt('Enter new name for printer:', printer.name);
+ if (newName && newName !== printer.name) {
+ socket.emit('action_rename', { id: printer.id, name: newName });
+ }
+ });
+}
+
+/**
+ * Format status for display
+ */
+function formatStatus(status) {
+ if (!status) return 'Unknown';
+
+ switch (status) {
+ case 'IDLE':
+ return ' Idle';
+ case 'PRINTING':
+ return ' Printing';
+ case 'FILE_TRANSFERRING':
+ return ' Transferring File';
+ case 'EXPOSURE_TESTING':
+ return ' Exposure Test';
+ case 'DEVICES_TESTING':
+ return ' Device Test';
+ default:
+ return `${status}`;
+ }
+}
+
+/**
+ * Format connection status for display
+ */
+function formatConnectionStatus(status) {
+ if (status === 'connected') {
+ return ' Connected';
+ } else {
+ return ' Disconnected';
+ }
+}
+
+/**
+ * Format print status for display
+ */
+function formatPrintStatus(status) {
+ switch (status) {
+ case 'IDLE':
+ return 'Idle';
+ case 'HOMING':
+ return 'Homing';
+ case 'DROPPING':
+ return 'Dropping';
+ case 'EXPOSURING':
+ return 'Exposing';
+ case 'LIFTING':
+ return 'Lifting';
+ case 'PAUSING':
+ return 'Pausing';
+ case 'PAUSED':
+ return 'Paused';
+ case 'STOPPING':
+ return 'Stopping';
+ case 'STOPED':
+ return 'Stopped';
+ case 'COMPLETE':
+ return 'Complete';
+ case 'FILE_CHECKING':
+ return 'Checking File';
+ default:
+ return `${status}`;
+ }
+}
+
+/**
+ * Format time in seconds to human-readable format
+ */
+function formatTime(seconds) {
+ if (!seconds) return 'Unknown';
+
+ const hrs = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ return `${hrs}h ${mins}m ${secs}s`;
+}
+
+/**
+ * Format file size in bytes to human-readable format
+ */
+function formatFileSize(bytes) {
+ if (bytes === 0) return '0 B';
+
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+}
+
+/**
+ * Handle printer status update
+ */
+function handlePrinterStatus(data) {
+ if (!data || !data.id) return;
+
+ const printerId = data.id;
+
+ // Update printer in our local cache
+ if (printers[printerId]) {
+ printers[printerId].machine_status = data.machine_status;
+ printers[printerId].print_status = data.print_status;
+ printers[printerId].print_progress = data.print_progress;
+ printers[printerId].current_file = data.current_file;
+ printers[printerId].remain_time = data.remain_time;
+ printers[printerId].status = 'connected';
+ printers[printerId].last_seen = Date.now() / 1000;
+
+ // Update UI if this is the current printer
+ if (currentPrinter === printerId) {
+ // Save current active tab before updating
+ const activeTabId = document.querySelector('#navTabs .nav-link.active')?.id;
+
+ // Update printer details
+ displayPrinterDetails(printerId);
+
+ // Restore active tab if one was selected
+ if (activeTabId) {
+ document.getElementById(activeTabId)?.click();
+ }
+ }
+
+ // Update printer list item
+ updatePrinterStatusInList(printerId);
+ }
+}
+
+/**
+ * Update printer status in the list
+ */
+function updatePrinterStatusInList(printerId) {
+ const printer = printers[printerId];
+ if (!printer) return;
+
+ const printerItem = document.querySelector(`.printerListItem[data-printer-id="${printerId}"]`);
+ if (!printerItem) return;
+
+ // Update status icon
+ const statusIcon = printerItem.querySelector('.printerStatus i');
+ if (printer.status === 'connected') {
+ statusIcon.className = 'bi bi-circle-fill text-success';
+ } else {
+ statusIcon.className = 'bi bi-circle-fill text-danger';
+ }
+
+ // Update info text
+ const infoEl = printerItem.querySelector('.printerInfo');
+ if (printer.machine_status === 'PRINTING' && printer.print_progress !== undefined) {
+ infoEl.textContent = `Printing: ${printer.print_progress}%`;
+ } else if (printer.machine_status) {
+ infoEl.textContent = printer.machine_status;
+ } else {
+ infoEl.textContent = printer.ip;
+ }
+}
+
+/**
+ * Handle printer attributes update
+ */
+function handlePrinterAttributes(data) {
+ if (!data || !data.id) return;
+
+ const printerId = data.id;
+ const attributes = data.attributes;
+
+ // Update printer in our local cache
+ if (printers[printerId] && attributes) {
+ // Update attributes
+ if (attributes.Resolution) {
+ printers[printerId].resolution = attributes.Resolution;
+ }
+
+ if (attributes.XYZsize) {
+ printers[printerId].build_volume = attributes.XYZsize;
+ }
+
+ if (attributes.CameraStatus !== undefined) {
+ printers[printerId].camera_status = attributes.CameraStatus === 1;
+ }
+
+ // Update UI if this is the current printer
+ if (currentPrinter === printerId) {
+ populateInfoTab(printers[printerId]);
+ }
+ }
+}
+
+/**
+ * Handle printer response
+ */
+function handlePrinterResponse(data) {
+ if (!data || !data.id) return;
+
+ const printerId = data.id;
+ const cmd = data.cmd;
+ const responseData = data.data;
+
+ // Handle file list response
+ if (cmd === 258 && responseData && responseData.FileList) {
+ displayFileList(printerId, responseData.FileList, responseData.Url || '/local');
+ }
+
+ // Handle other responses
+ switch (cmd) {
+ case 259: // Delete file
+ showToast('File deleted successfully', 'success');
+ confirmModal.hide();
+ break;
+ case 128: // Start print
+ showToast('Print started successfully', 'success');
+ confirmModal.hide();
+ break;
+ case 129: // Pause print
+ showToast('Print paused', 'success');
+ break;
+ case 130: // Stop print
+ showToast('Print stopped', 'success');
+ confirmModal.hide();
+ break;
+ case 131: // Resume print
+ showToast('Print resumed', 'success');
+ break;
+ case 192: // Rename printer
+ if (printers[printerId] && responseData && responseData.success) {
+ showToast('Printer renamed successfully', 'success');
+ // Update name in our local cache
+ if (responseData.name) {
+ printers[printerId].name = responseData.name;
+
+ // Update UI
+ if (currentPrinter === printerId) {
+ document.getElementById('printerName').textContent = responseData.name;
+ }
+
+ // Update printer list item
+ const printerItem = document.querySelector(`.printerListItem[data-printer-id="${printerId}"]`);
+ if (printerItem) {
+ printerItem.querySelector('.printerName').textContent = responseData.name;
+ }
+ }
+ }
+ break;
+ }
+}
+
+/**
+ * Display file list
+ */
+function displayFileList(printerId, files, path) {
+ const filesList = document.getElementById('files-list');
+ if (!filesList) return;
+
+ if (!files || files.length === 0) {
+ filesList.innerHTML = `No files found in ${path}
`;
+ return;
+ }
+
+ filesList.innerHTML = '';
+
+ // Sort files: directories first, then by name
+ const sortedFiles = [...files].sort((a, b) => {
+ if (a.type === 0 && b.type !== 0) return -1;
+ if (a.type !== 0 && b.type === 0) return 1;
+ return (a.name || a.FileName || '').localeCompare(b.name || b.FileName || '');
+ });
+
+ sortedFiles.forEach(file => {
+ const isDirectory = file.type === 0;
+ // Handle different file property naming conventions
+ const fileName = file.name || file.FileName || 'Unknown';
+ const fileSize = formatFileSize(file.FileSize || file.fileSize || 0);
+ const fileDate = file.CreationTime
+ ? new Date(file.CreationTime * 1000).toLocaleString()
+ : 'Unknown date';
+
+ const item = document.createElement('div');
+ item.className = 'list-group-item';
+
+ if (isDirectory) {
+ // Directory item
+ item.innerHTML = `
+
+
+
+ ${fileName}
+
+
+
+ `;
+
+ // Add event listener for browsing folder
+ item.querySelector('.browse-folder').addEventListener('click', () => {
+ socket.emit('printer_files', { id: printerId, url: fileName });
+ filesList.innerHTML = `
+
+ `;
+ });
+ } else {
+ // File item
+ item.innerHTML = `
+
+
+
+
${fileName}
+
+ ${fileDate} - ${fileSize}
+
+
+
+
+
+
+
+ `;
+
+ // Add event listeners for file actions
+ item.querySelector('.btn-print-file').addEventListener('click', () => {
+ showConfirmModal('print', fileName);
+ });
+
+ item.querySelector('.btn-delete-file').addEventListener('click', () => {
+ showConfirmModal('delete', fileName);
+ });
+ }
+
+ filesList.appendChild(item);
+ });
+}
+
+/**
+ * Handle printer error
+ */
+function handlePrinterError(data) {
+ if (!data) return;
+
+ const errorCode = data.error_code;
+ const errorMessage = data.error_message || `Error code: ${errorCode}`;
+
+ showToast(`Printer error: ${errorMessage}`, 'error');
+}
+
+/**
+ * Handle printer notice
+ */
+function handlePrinterNotice(data) {
+ if (!data) return;
+
+ const message = data.message || 'Notification from printer';
+
+ showToast(message, 'info');
+}
+
+/**
+ * Show confirmation modal
+ */
+function showConfirmModal(action, value) {
+ const modalTitle = document.getElementById('modalConfirmTitle');
+ const modalAction = document.getElementById('modalConfirmAction');
+ const modalValue = document.getElementById('modalConfirmValue');
+ const confirmBtn = document.getElementById('btnConfirm');
+
+ // Check if elements exist
+ if (!modalTitle || !modalAction || !modalValue || !confirmBtn) {
+ console.error('Modal elements not found');
+ return;
+ }
+
+ // Get modal element and initialize if not already done
+ const modalEl = document.getElementById('modalConfirm');
+ if (!modalEl) {
+ console.error('Modal element not found');
+ return;
+ }
+
+ // Initialize modal if not already initialized
+ if (!confirmModal) {
+ confirmModal = new bootstrap.Modal(modalEl);
+ }
+
+ // Set title based on action
+ switch (action) {
+ case 'print':
+ modalTitle.textContent = 'Confirm Print';
+ break;
+ case 'delete':
+ modalTitle.textContent = 'Confirm Delete';
+ break;
+ case 'stop':
+ modalTitle.textContent = 'Confirm Stop Print';
+ break;
+ default:
+ modalTitle.textContent = `Confirm ${action}`;
+ }
+
+ modalAction.textContent = action;
+ modalValue.textContent = value;
+
+ confirmBtn.dataset.action = action;
+ confirmBtn.dataset.value = value;
+
+ // Show the modal
+ confirmModal.show();
+}
+
+/**
+ * Handle confirm button click
+ */
+function handleConfirmAction() {
+ const action = document.getElementById('btnConfirm').dataset.action;
+ const value = document.getElementById('btnConfirm').dataset.value;
+ const printerId = document.getElementById('btnConfirm').dataset.printerId;
+
+ if (!action) return;
+
+ // Handle printer deletion
+ if (action === 'delete-printer' && printerId) {
+ deletePrinter(printerId);
+ return;
+ }
+
+ // Handle file actions
+ if (!currentPrinter) {
+ showToast('No printer selected', 'error');
+ if (confirmModal) confirmModal.hide();
+ return;
+ }
+
+ switch (action) {
+ case 'print':
+ socket.emit('action_print', { id: currentPrinter, data: value });
+ break;
+ case 'delete':
+ socket.emit('action_delete', { id: currentPrinter, data: value });
+ break;
+ case 'stop':
+ socket.emit('action_stop', { id: currentPrinter });
+ break;
+ }
+}
+/**
+ * Handle upload button click
+ */
+function handleUploadClick() {
+ if (!currentPrinter) {
+ showToast('Please select a printer first', 'warning');
+ return;
+ }
+
+ const fileInput = document.getElementById('uploadFile');
+ const file = fileInput.files[0];
+
+ if (!file) {
+ showToast('Please select a file to upload', 'warning');
+ return;
+ }
+
+ // Check file extension
+ const fileExt = file.name.split('.').pop().toLowerCase();
+ if (!['ctb', 'goo', 'prz'].includes(fileExt)) {
+ showToast('Invalid file type. Only .ctb, .goo, and .prz files are supported.', 'error');
+ return;
+ }
+
+ // Create FormData and submit
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('printer', currentPrinter);
+
+ fetch('/upload', {
+ method: 'POST',
+ body: formData
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showToast('File upload started', 'success');
+ fileInput.value = ''; // Clear file input
+
+ // Reset progress bar
+ const progressBar = document.getElementById('progressUpload');
+ progressBar.style.width = '0%';
+ progressBar.textContent = '0%';
+ } else {
+ showToast(data.error || 'Upload failed', 'error');
+ }
+ })
+ .catch(error => {
+ showToast('Error uploading file', 'error');
+ console.error('Upload error:', error);
+ });
+}
+
+/**
+ * Handle upload progress update
+ */
+function handleUploadProgress(data) {
+ if (!data || !data.id) return;
+
+ // Store task in global state
+ uploadTasks[data.id] = data;
+
+ // Update UI if needed
+ if (data.printer_id === currentPrinter) {
+ // Update progress bar in upload form
+ const progressBar = document.getElementById('progressUpload');
+ if (progressBar) {
+ progressBar.style.width = `${data.progress}%`;
+ progressBar.textContent = `${data.progress}%`;
+
+ if (data.status === 'complete') {
+ progressBar.classList.add('bg-success');
+ setTimeout(() => {
+ progressBar.style.width = '0%';
+ progressBar.textContent = '0%';
+ progressBar.classList.remove('bg-success');
+ }, 3000);
+
+ // Refresh file list
+ socket.emit('printer_files', { id: currentPrinter, url: '/local' });
+ }
+ }
+ }
+
+ // Update upload tasks display
+ updateUploadTasksDisplay();
+}
+
+/**
+ * Update upload tasks display
+ */
+function updateUploadTasksDisplay() {
+ const container = document.getElementById('uploadProgressContainer');
+ if (!container) return;
+
+ container.innerHTML = '';
+
+ const activeTasks = Object.values(uploadTasks).filter(
+ task => task.status !== 'complete' && task.status !== 'error'
+ );
+
+ const recentCompletedTasks = Object.values(uploadTasks)
+ .filter(task => task.status === 'complete' || task.status === 'error')
+ .sort((a, b) => b.updated_at - a.updated_at)
+ .slice(0, 3);
+
+ const allTasks = [...activeTasks, ...recentCompletedTasks];
+
+ if (allTasks.length === 0) {
+ container.innerHTML = 'No active uploads
';
+ return;
+ }
+
+ const template = document.getElementById('tmplUploadProgress');
+
+ allTasks.forEach(task => {
+ const item = template.content.cloneNode(true);
+
+ item.querySelector('.upload-item').dataset.taskId = task.id;
+ item.querySelector('.upload-filename').textContent = task.filename;
+ item.querySelector('.upload-printer').textContent = `To: ${task.printer_name}`;
+
+ const statusBadge = item.querySelector('.upload-status');
+ const progressBar = item.querySelector('.upload-progress-bar');
+
+ // Set status
+ switch (task.status) {
+ case 'starting':
+ statusBadge.textContent = 'Starting';
+ statusBadge.className = 'badge upload-status bg-secondary';
+ break;
+ case 'uploading':
+ statusBadge.textContent = 'Uploading';
+ statusBadge.className = 'badge upload-status bg-primary';
+ progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
+ break;
+ case 'complete':
+ statusBadge.textContent = 'Complete';
+ statusBadge.className = 'badge upload-status bg-success';
+ progressBar.classList.add('bg-success');
+ break;
+ case 'error':
+ statusBadge.textContent = 'Error';
+ statusBadge.className = 'badge upload-status bg-danger';
+ progressBar.classList.add('bg-danger');
+ break;
+ }
+
+ // Set progress
+ progressBar.style.width = `${task.progress}%`;
+ progressBar.setAttribute('aria-valuenow', task.progress);
+
+ container.appendChild(item);
+ });
+
+ // Update count badge
+ updateUploadCount();
+}
+
+/**
+ * Update upload count badge
+ */
+function updateUploadCount() {
+ const activeTasks = Object.values(uploadTasks).filter(
+ task => task.status !== 'complete' && task.status !== 'error'
+ );
+
+ const countElement = document.getElementById('uploadCount');
+ if (countElement) {
+ countElement.textContent = `${activeTasks.length} active`;
+ }
+}
+
+/**
+ * Start camera stream
+ */
+function startCameraStream(printerId) {
+ if (!printerId || !printers[printerId] || !printers[printerId].supports_camera) return;
+
+ // Enable camera on printer
+ socket.emit('action_camera', { id: printerId, enable: true });
+
+ // Show loading indicator
+ const placeholder = document.getElementById('cameraPlaceholder');
+ placeholder.innerHTML = `
+
+
+
Connecting to camera...
+
+ `;
+
+ // Get stream image
+ const img = document.getElementById('cameraStream');
+
+ // Show start/stop buttons
+ document.getElementById('btnStartStream').classList.add('d-none');
+ document.getElementById('btnStopStream').classList.remove('d-none');
+
+ // Load first image
+ const timestamp = new Date().getTime();
+ img.src = `/camera/${printerId}/stream?t=${timestamp}`;
+
+ // When image loads, hide placeholder and show image
+ img.onload = () => {
+ placeholder.classList.add('d-none');
+ img.classList.remove('d-none');
+
+ // Set streaming flag
+ cameraStreaming = true;
+
+ // Start refresh interval
+ if (cameraInterval) {
+ clearInterval(cameraInterval);
+ }
+
+ cameraInterval = setInterval(() => {
+ const newTimestamp = new Date().getTime();
+ img.src = `/camera/${printerId}/stream?t=${newTimestamp}`;
+ }, 1000);
+ };
+
+ // Handle errors
+ img.onerror = () => {
+ placeholder.innerHTML = `
+
+
+
Failed to connect to camera
+
+
+ `;
+
+ img.classList.add('d-none');
+
+ // Show start button, hide stop button
+ document.getElementById('btnStartStream').classList.remove('d-none');
+ document.getElementById('btnStopStream').classList.add('d-none');
+
+ // Reset streaming state
+ cameraStreaming = false;
+ if (cameraInterval) {
+ clearInterval(cameraInterval);
+ cameraInterval = null;
+ }
+
+ // Add retry button handler
+ document.getElementById('btnRetryCamera')?.addEventListener('click', () => {
+ startCameraStream(printerId);
+ });
+ };
+}
+
+/**
+ * Stop camera stream
+ */
+function stopCameraStream() {
+ // Stop refresh interval
+ if (cameraInterval) {
+ clearInterval(cameraInterval);
+ cameraInterval = null;
+ }
+
+ // Reset UI
+ const placeholder = document.getElementById('cameraPlaceholder');
+ const img = document.getElementById('cameraStream');
+
+ placeholder.classList.remove('d-none');
+ placeholder.innerHTML = `
+
+
+
Click Start Stream to view camera feed
+
+ `;
+
+ img.classList.add('d-none');
+
+ // Show start button, hide stop button
+ document.getElementById('btnStartStream').classList.remove('d-none');
+ document.getElementById('btnStopStream').classList.add('d-none');
+
+ // Set streaming flag
+ cameraStreaming = false;
+
+ // Disable camera on printer
+ if (currentPrinter) {
+ socket.emit('action_camera', { id: currentPrinter, enable: false });
+ }
+}
+
+/**
+ * Set server connection status
+ */
+function setServerStatus(online) {
+ const serverStatus = document.querySelector('.serverStatus');
+ if (online) {
+ serverStatus.classList.remove('bi-cloud', 'text-danger');
+ serverStatus.classList.add('bi-cloud-check-fill', 'text-success');
+ } else {
+ serverStatus.classList.remove('bi-cloud-check-fill', 'text-success');
+ serverStatus.classList.add('bi-cloud', 'text-danger');
+ }
+}
+
+/**
+ * Show toast notification
+ */
+function showToast(message, type = 'info') {
+ const toastEl = document.getElementById('toastUpload');
+ const toastBody = toastEl.querySelector('.toast-body');
+ const toastHeader = toastEl.querySelector('.toast-header');
+ const toastTime = document.getElementById('toastTime');
+
+ // Set message
+ toastBody.textContent = message;
+ toastTime.textContent = 'just now';
+
+ // Set icon and color based on type
+ const iconEl = toastHeader.querySelector('i');
+
+ switch (type) {
+ case 'success':
+ iconEl.className = 'bi bi-check-circle-fill text-success me-2';
+ break;
+ case 'error':
+ iconEl.className = 'bi bi-exclamation-circle-fill text-danger me-2';
+ break;
+ case 'warning':
+ iconEl.className = 'bi bi-exclamation-triangle-fill text-warning me-2';
+ break;
+ default:
+ iconEl.className = 'bi bi-info-circle-fill text-primary me-2';
+ }
+
+ // Show toast
+ const toast = bootstrap.Toast.getOrCreateInstance(toastEl);
+ toast.show();
+}
+
+// Global variables (add these to your existing global variables)
+let scanModal = null;
+let scanInProgress = false;
+let discoveredPrinters = 0;
+
+document.addEventListener('DOMContentLoaded', () => {
+ initDefaultImages();
+
+ refreshStatus();
+ setInterval(refreshStatus, 30_000);
+
+ // Initialize scan modal
+ scanModal = new bootstrap.Modal(document.getElementById('modalScanLAN'));
+
+ // Add scan LAN button event listener
+ document.getElementById('btnScanLAN').addEventListener('click', startLANScan);
+
+ // Add socket event handlers for scan progress
+ socket.on('scan_progress', handleScanProgress);
+ socket.on('printer_discovered', handlePrinterDiscovered);
+});
+
+/**s
+ * Start a LAN scan for printers
+ */
+function startLANScan() {
+ if (scanInProgress) return;
+
+ scanInProgress = true;
+ discoveredPrinters = 0;
+
+ // Reset UI elements
+ document.getElementById('scanProgress').style.width = '0%';
+ document.getElementById('scanStatus').textContent = 'Initializing scan...';
+ document.getElementById('scanStatus').className = 'alert alert-info';
+ document.getElementById('foundPrinters').classList.add('d-none');
+ document.getElementById('printerScanList').innerHTML = '';
+
+ // Add search status
+ const scanDetails = document.createElement('div');
+ scanDetails.className = 'scan-details small text-muted mt-2';
+ scanDetails.innerHTML = `
+
+
Scanning network interfaces...
+
+ `;
+ document.getElementById('scanStatus').appendChild(scanDetails);
+
+ // Show modal
+ scanModal.show();
+
+ // Start scan with 8 second timeout for more thorough scanning
+ socket.emit('scan_lan', { timeout: 8 });
+
+ // Set timeout to ensure scan doesn't hang
+ setTimeout(() => {
+ if (scanInProgress) {
+ completeScan(discoveredPrinters > 0);
+ }
+ }, 30000); // 30 second max timeout for thorough scan
+
+ // Function to complete scan
+ function completeScan(success) {
+ scanInProgress = false;
+
+ // Update UI
+ document.getElementById('scanProgress').style.width = '100%';
+ document.getElementById('scanProgress').classList.remove('progress-bar-animated');
+
+ if (success) {
+ document.getElementById('scanStatus').textContent = `Scan complete! Found ${discoveredPrinters} printer(s).`;
+ document.getElementById('scanStatus').className = 'alert alert-success';
+ document.getElementById('foundPrinters').classList.remove('d-none');
+ } else {
+ document.getElementById('scanStatus').textContent = 'No printers found on your network.';
+ document.getElementById('scanStatus').className = 'alert alert-warning';
+
+ // Add troubleshooting tips
+ const tips = document.createElement('div');
+ tips.className = 'mt-3 small';
+ tips.innerHTML = `
+ Troubleshooting tips:
+
+ - Make sure your printer is powered on and connected to the network
+ - Ensure your computer and printer are on the same network
+ - Try adding your printer manually using its IP address
+ - Check if your firewall is blocking UDP port 3000
+ - Temporarily disable VPN software if you're using any
+
+ `;
+ document.getElementById('scanStatus').appendChild(tips);
+ }
+ }
+}
+
+/**
+ * Handle scan progress update from server with more detailed information
+ */
+function handleScanProgress(data) {
+ if (!scanInProgress) return;
+
+ // Update progress if provided
+ if (data.progress) {
+ document.getElementById('scanProgress').style.width = `${data.progress}%`;
+ }
+
+ // Update status text if provided
+ if (data.status) {
+ const mainStatus = document.getElementById('scanStatus');
+ if (mainStatus.childNodes.length === 0 || mainStatus.childNodes[0].nodeType === Node.TEXT_NODE) {
+ mainStatus.textContent = data.status;
+ } else {
+ mainStatus.childNodes[0].textContent = data.status;
+ }
+ }
+
+ // Add scan details if available
+ if (data.details) {
+ const detailsEl = document.getElementById('scanDetails');
+ if (detailsEl) {
+ const detail = document.createElement('div');
+ detail.textContent = data.details;
+ detailsEl.appendChild(detail);
+
+ // Scroll to bottom
+ detailsEl.scrollTop = detailsEl.scrollHeight;
+ }
+ }
+
+ // Complete scan if done
+ if (data.complete) {
+ scanInProgress = false;
+
+ // Update UI
+ document.getElementById('scanProgress').style.width = '100%';
+ document.getElementById('scanProgress').classList.remove('progress-bar-animated');
+
+ if (data.success) {
+ document.getElementById('scanStatus').textContent = `Scan complete! Found ${discoveredPrinters} printer(s).`;
+ document.getElementById('scanStatus').className = 'alert alert-success';
+ document.getElementById('foundPrinters').classList.remove('d-none');
+ } else {
+ document.getElementById('scanStatus').textContent = 'No printers found on your network.';
+ document.getElementById('scanStatus').className = 'alert alert-warning';
+
+ // Add a button to manually add a printer
+ const addManuallyBtn = document.createElement('button');
+ addManuallyBtn.className = 'btn btn-primary mt-3';
+ addManuallyBtn.innerHTML = 'Add Printer Manually';
+ addManuallyBtn.addEventListener('click', () => {
+ scanModal.hide();
+ addPrinterModal.show();
+ });
+ document.getElementById('scanStatus').appendChild(addManuallyBtn);
+ }
+ }
+}
+
+/**
+ * Handle newly discovered printer from server
+ */
+function handlePrinterDiscovered(printer) {
+ if (!scanInProgress) return;
+
+ discoveredPrinters++;
+
+ // Add to found printers list
+ const listItem = document.createElement('li');
+ listItem.className = 'list-group-item d-flex justify-content-between align-items-center';
+ listItem.innerHTML = `
+
+
${printer.name}
+
${printer.ip} - ${printer.model}
+
+
+ `;
+
+ // Add event listener for the add button
+ listItem.querySelector('.add-discovered-printer').addEventListener('click', (e) => {
+ const btn = e.currentTarget;
+ const printerData = {
+ name: btn.dataset.printerName,
+ ip: btn.dataset.printerIp,
+ model: btn.dataset.printerModel,
+ brand: btn.dataset.printerBrand
+ };
+
+ // Call addPrinterFromScan
+ addPrinterFromScan(printerData);
+
+ // Update button to show it's being added
+ btn.disabled = true;
+ btn.innerHTML = ' Adding...';
+ });
+
+ document.getElementById('printerScanList').appendChild(listItem);
+ document.getElementById('foundPrinters').classList.remove('d-none');
+}
+
+/**
+ * Add a printer from scan results
+ */
+function addPrinterFromScan(printerData) {
+ // Send request to add printer
+ fetch('/api/printer/add', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(printerData)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showToast(`Printer ${printerData.name} added successfully`, 'success');
+
+ // Refresh printer list
+ socket.emit('printers');
+
+ // Select the new printer if available
+ if (data.printer_id) {
+ setTimeout(() => {
+ selectPrinter(data.printer_id);
+ }, 500);
+ }
+ } else {
+ showToast(data.error || 'Failed to add printer', 'error');
+ }
+ })
+ .catch(error => {
+ showToast('Error adding printer', 'error');
+ console.error('Error:', error);
+ });
+}
+
+// Enhance the existing initSocketIO function to handle connection issues
+function initSocketIO() {
+ socket = io({
+ reconnectionAttempts: 5, // Try to reconnect 5 times
+ reconnectionDelay: 1000, // Start with a 1 second delay
+ reconnectionDelayMax: 5000, // Maximum delay of 5 seconds
+ timeout: 20000 // Timeout after 20 seconds
+ });
+
+ // Connection events
+ socket.on('connect', () => {
+ console.log('Connected to server');
+ setServerStatus(true);
+
+ // If reconnected, refresh data
+ socket.emit('printers');
+ });
+
+ socket.on('disconnect', () => {
+ console.log('Disconnected from server');
+ setServerStatus(false);
+ });
+
+ socket.on('connect_error', (error) => {
+ console.error('Connection error:', error);
+ setServerStatus(false);
+ showToast('Connection to server failed. Retrying...', 'error');
+ });
+
+ socket.on('reconnect_failed', () => {
+ console.error('Failed to reconnect to server');
+ showToast('Could not connect to server. Please check if the server is running.', 'error');
+ });
+
+ // Data events
+ socket.on('printers', handlePrinters);
+ socket.on('printer_status', handlePrinterStatus);
+ socket.on('printer_attributes', handlePrinterAttributes);
+ socket.on('printer_response', handlePrinterResponse);
+ socket.on('printer_error', handlePrinterError);
+ socket.on('printer_notice', handlePrinterNotice);
+ socket.on('upload_progress', handleUploadProgress);
+}
+
+function handleAddPrinter(e) {
+ e.preventDefault();
+
+ // Get form inputs
+ const form = document.getElementById('formAddPrinter');
+ const name = form.querySelector('[name="name"]').value.trim();
+ const ip = form.querySelector('[name="ip"]').value.trim();
+ const model = form.querySelector('[name="model"]').value;
+ const brand = form.querySelector('[name="brand"]').value;
+
+ // Enhanced validation
+ const errors = [];
+ if (!name) errors.push("Printer name is required");
+ if (!ip) errors.push("IP address is required");
+
+ // IP format validation
+ const ipRegex = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
+ if (ip && !ipRegex.test(ip)) {
+ errors.push("Invalid IP address format (e.g., 192.168.1.100)");
+ } else if (ip) {
+ // Validate each octet is 0-255
+ const octets = ip.split('.');
+ for (const octet of octets) {
+ const num = parseInt(octet, 10);
+ if (num < 0 || num > 255) {
+ errors.push("IP address octets must be between 0 and 255");
+ break;
+ }
+ }
+ }
+
+ // Display validation errors
+ if (errors.length > 0) {
+ const errorMsg = errors.join(". ");
+ showToast(errorMsg, 'error');
+ return;
+ }
+
+ // Show loading state
+ const submitBtn = form.querySelector('button[type="submit"]');
+ const originalBtnText = submitBtn.innerHTML;
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = 'Adding...';
+
+ // First run a connectivity test
+ fetch('/api/printer/diagnostics', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ ip })
+ })
+ .then(response => response.json())
+ .then(diagnostic => {
+ if (diagnostic.success && (diagnostic.results.overall.status === 'success' || diagnostic.results.ping.status === 'success')) {
+ // IP is reachable, proceed with adding printer
+ return fetch('/api/printer/add', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ name, ip, model, brand })
+ });
+ } else {
+ // IP is not reachable, but allow user to continue with warning
+ const confirmContinue = confirm(`Warning: Printer at ${ip} seems unreachable. Add anyway?`);
+ if (confirmContinue) {
+ return fetch('/api/printer/add', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ name, ip, model, brand })
+ });
+ } else {
+ throw new Error('Canceled by user');
+ }
+ }
+ })
+ .then(response => response.json())
+ .then(data => {
+ // Reset button state
+ submitBtn.disabled = false;
+ submitBtn.innerHTML = originalBtnText;
+
+ if (data.success) {
+ showToast(`Printer "${name}" added successfully`, 'success');
+ addPrinterModal.hide();
+
+ // Reset form
+ form.reset();
+
+ // Refresh printer list
+ socket.emit('printers');
+
+ // Select the new printer if available
+ if (data.printer_id) {
+ setTimeout(() => {
+ selectPrinter(data.printer_id);
+ }, 500);
+ }
+ } else {
+ showToast(data.error || 'Failed to add printer', 'error');
+ }
+ })
+ .catch(error => {
+ // Reset button state
+ submitBtn.disabled = false;
+ submitBtn.innerHTML = originalBtnText;
+
+ if (error.message === 'Canceled by user') {
+ showToast('Printer addition canceled', 'info');
+ } else {
+ showToast('Error adding printer: Network error', 'error');
+ console.error('Error:', error);
+ }
+ });
+}
+
+function createCameraTab(printer) {
+ // Only create camera tab if printer supports it
+ if (!printer.supports_camera) return;
+
+ const cameraPaneEl = document.getElementById('camera-pane');
+ if (!cameraPaneEl) return;
+
+ cameraPaneEl.innerHTML = `
+
+
Camera Stream
+
+
+
+
+
+
+
+
+
+
Click Start Stream to view camera feed
+
+
![Camera stream]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Unknown
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Add event listeners for camera controls
+ document.getElementById('btnStartStream').addEventListener('click', () => {
+ startCameraStream(printer.id);
+ });
+
+ document.getElementById('btnStopStream').addEventListener('click', () => {
+ stopCameraStream();
+ });
+
+ document.getElementById('btnCameraSettings').addEventListener('click', () => {
+ toggleCameraControls();
+ });
+
+ document.getElementById('cameraRefreshRate').addEventListener('change', (e) => {
+ updateCameraRefreshRate(parseInt(e.target.value, 10));
+ });
+
+ document.getElementById('cameraEnabledSwitch').addEventListener('change', (e) => {
+ setCameraEnabled(printer.id, e.target.checked);
+ });
+
+ document.getElementById('btnRefreshCameraStatus').addEventListener('click', () => {
+ checkCameraStatus(printer.id);
+ });
+
+ // Initialize camera status
+ checkCameraStatus(printer.id);
+}
+
+// Toggle camera controls visibility
+function toggleCameraControls() {
+ const controls = document.querySelector('.camera-controls');
+ if (controls) {
+ controls.classList.toggle('d-none');
+ }
+}
+
+// Update camera refresh rate
+function updateCameraRefreshRate(rate) {
+ if (cameraInterval) {
+ clearInterval(cameraInterval);
+ cameraInterval = null;
+ }
+
+ if (cameraStreaming) {
+ // Restart stream with new refresh rate
+ cameraInterval = setInterval(() => {
+ const img = document.getElementById('cameraStream');
+ if (img) {
+ const newTimestamp = new Date().getTime();
+ img.src = `/camera/${currentPrinter}/stream?t=${newTimestamp}`;
+ }
+ }, rate);
+ }
+}
+
+// Check camera status via API
+function checkCameraStatus(printerId) {
+ if (!printerId) return;
+
+ const statusEl = document.getElementById('cameraStatus');
+ const switchEl = document.getElementById('cameraEnabledSwitch');
+
+ if (!statusEl || !switchEl) return;
+
+ // Set loading state
+ statusEl.className = 'badge bg-info me-2';
+ statusEl.textContent = 'Checking...';
+
+ // Request camera status via API
+ fetch(`/api/printer/${printerId}/camera/status`)
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ // Update UI
+ const enabled = data.enabled;
+ statusEl.className = enabled ? 'badge bg-success me-2' : 'badge bg-danger me-2';
+ statusEl.textContent = enabled ? 'Enabled' : 'Disabled';
+ switchEl.checked = enabled;
+ } else {
+ // Show error
+ statusEl.className = 'badge bg-warning me-2';
+ statusEl.textContent = 'Error';
+ }
+ })
+ .catch(error => {
+ console.error('Error checking camera status:', error);
+ statusEl.className = 'badge bg-danger me-2';
+ statusEl.textContent = 'Error';
+ });
+}
+
+// Enable/disable camera on printer
+function setCameraEnabled(printerId, enabled) {
+ if (!printerId) return;
+
+ // Send request to API endpoint
+ fetch(`/api/printer/${printerId}/camera`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ enable: enabled
+ })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ // Show success message
+ showToast(
+ `Camera ${enabled ? 'enabled' : 'disabled'} successfully`,
+ 'success'
+ );
+
+ // Update UI
+ checkCameraStatus(printerId);
+ } else {
+ // Show error
+ showToast(
+ `Failed to ${enabled ? 'enable' : 'disable'} camera: ${data.error || 'Unknown error'}`,
+ 'error'
+ );
+ }
+ })
+ .catch(error => {
+ console.error('Error setting camera status:', error);
+ showToast(
+ `Error setting camera status: ${error.message || 'Network error'}`,
+ 'error'
+ );
+ });
+}
+function startCameraStream(printerId) {
+ if (!printerId || !printers[printerId] || !printers[printerId].supports_camera) return;
+
+ // Show loading indicator
+ const placeholder = document.getElementById('cameraPlaceholder');
+ placeholder.innerHTML = `
+
+
+
Connecting to camera...
+
+ `;
+
+ // Get stream image element
+ const img = document.getElementById('cameraStream');
+
+ // Show start/stop buttons
+ document.getElementById('btnStartStream').classList.add('d-none');
+ document.getElementById('btnStopStream').classList.remove('d-none');
+
+ // Enable camera on printer via API
+ fetch(`/api/printer/${printerId}/camera`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ enable: true
+ })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ // Load first image
+ const timestamp = new Date().getTime();
+ img.src = `/camera/${printerId}/stream?t=${timestamp}`;
+
+ // When image loads, hide placeholder and show image
+ img.onload = () => {
+ placeholder.classList.add('d-none');
+ img.classList.remove('d-none');
+
+ // Set streaming flag
+ cameraStreaming = true;
+
+ // Get refresh rate from select
+ const refreshRate = parseInt(document.getElementById('cameraRefreshRate').value, 10) || 1000;
+
+ // Start refresh interval
+ if (cameraInterval) {
+ clearInterval(cameraInterval);
+ }
+
+ cameraInterval = setInterval(() => {
+ const newTimestamp = new Date().getTime();
+ img.src = `/camera/${printerId}/stream?t=${newTimestamp}`;
+ }, refreshRate);
+
+ // Show camera controls
+ document.querySelector('.camera-controls')?.classList.remove('d-none');
+
+ // Update status
+ checkCameraStatus(printerId);
+ };
+
+ // Handle errors
+ img.onerror = () => {
+ handleCameraError(placeholder);
+ };
+ } else {
+ // Show error
+ handleCameraError(placeholder, data.error || 'Failed to enable camera');
+ }
+ })
+ .catch(error => {
+ console.error('Error enabling camera:', error);
+ handleCameraError(placeholder, error.message || 'Network error');
+ });
+}
+
+function handleCameraError(placeholder, errorMsg = null) {
+ placeholder.innerHTML = `
+
+
+
Failed to connect to camera
+ ${errorMsg ? `
${errorMsg}
` : ''}
+
+
+ `;
+
+ document.getElementById('cameraStream').classList.add('d-none');
+
+ // Show start button, hide stop button
+ document.getElementById('btnStartStream').classList.remove('d-none');
+ document.getElementById('btnStopStream').classList.add('d-none');
+
+ // Reset streaming state
+ cameraStreaming = false;
+ if (cameraInterval) {
+ clearInterval(cameraInterval);
+ cameraInterval = null;
+ }
+
+ // Add retry button handler
+ document.getElementById('btnRetryCamera')?.addEventListener('click', () => {
+ startCameraStream(currentPrinter);
+ });
+}
+
+function stopCameraStream() {
+ // Stop refresh interval
+ if (cameraInterval) {
+ clearInterval(cameraInterval);
+ cameraInterval = null;
+ }
+
+ // Reset UI
+ const placeholder = document.getElementById('cameraPlaceholder');
+ const img = document.getElementById('cameraStream');
+
+ placeholder.classList.remove('d-none');
+ placeholder.innerHTML = `
+
+
+
Click Start Stream to view camera feed
+
+ `;
+
+ img.classList.add('d-none');
+
+ // Show start button, hide stop button
+ document.getElementById('btnStartStream').classList.remove('d-none');
+ document.getElementById('btnStopStream').classList.add('d-none');
+
+ // Set streaming flag
+ cameraStreaming = false;
+
+ // Disable camera on printer
+ if (currentPrinter) {
+ fetch(`/api/printer/${currentPrinter}/camera`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ enable: false
+ })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ // Update status
+ checkCameraStatus(currentPrinter);
+ }
+ })
+ .catch(error => {
+ console.error('Error disabling camera:', error);
+ });
+ }
+}
+
+function handleCameraError(placeholder, errorMsg = null) {
+ placeholder.innerHTML = `
+
+
+
Failed to connect to camera
+ ${errorMsg ? `
${errorMsg}
` : ''}
+
+
+ `;
+
+ document.getElementById('cameraStream').classList.add('d-none');
+
+ // Show start button, hide stop button
+ document.getElementById('btnStartStream').classList.remove('d-none');
+ document.getElementById('btnStopStream').classList.add('d-none');
+
+ // Reset streaming state
+ cameraStreaming = false;
+ if (cameraInterval) {
+ clearInterval(cameraInterval);
+ cameraInterval = null;
+ }
+
+ // Add retry button handler
+ document.getElementById('btnRetryCamera')?.addEventListener('click', () => {
+ startCameraStream(currentPrinter);
+ });
+}
+
+function stopCameraStream() {
+ // Stop refresh interval
+ if (cameraInterval) {
+ clearInterval(cameraInterval);
+ cameraInterval = null;
+ }
+
+ // Reset UI
+ const placeholder = document.getElementById('cameraPlaceholder');
+ const img = document.getElementById('cameraStream');
+
+ placeholder.classList.remove('d-none');
+ placeholder.innerHTML = `
+
+
+
Click Start Stream to view camera feed
+
+ `;
+
+ img.classList.add('d-none');
+
+ // Show start button, hide stop button
+ document.getElementById('btnStartStream').classList.remove('d-none');
+ document.getElementById('btnStopStream').classList.add('d-none');
+
+ // Set streaming flag
+ cameraStreaming = false;
+
+ // Disable camera on printer
+ if (currentPrinter) {
+ fetch(`/api/printer/${currentPrinter}/camera`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ enable: false
+ })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ // Update status
+ checkCameraStatus(currentPrinter);
+ }
+ })
+ .catch(error => {
+ console.error('Error disabling camera:', error);
+ });
+ }
+}
+
+// Add this to the initialization code in chitui.js
+function initDefaultImages() {
+ // Create a default SVG printer icon as base64
+ window.DEFAULT_PRINTER_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCI+PHJlY3Qgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiIGZpbGw9IiMzMzMiIHJ4PSIxMCIvPjxwYXRoIGQ9Ik0zMCA3MGg0MHYxNUgzMHoiIGZpbGw9IiM2NjYiLz48cGF0aCBkPSJNMjUgMzBoNTB2MzBIMjV6IiBmaWxsPSIjNjY2Ii8+PHBhdGggZD0iTTM1IDIwaDMwdjEwSDM1eiIgZmlsbD0iIzY2NiIvPjxjaXJjbGUgY3g9IjY1IiBjeT0iNDUiIHI9IjUiIGZpbGw9IiM5OTkiLz48L3N2Zz4=';
+
+ // Add global error handler for images
+ document.addEventListener('error', function(e) {
+ if (e.target.tagName === 'IMG') {
+ // For printer icons
+ if (e.target.classList.contains('printerIcon')) {
+ e.target.src = window.DEFAULT_PRINTER_ICON;
+ }
+ // For the main printer image
+ else if (e.target.id === 'printerIcon') {
+ e.target.src = window.DEFAULT_PRINTER_ICON;
+ }
+ }
+ }, true);
+}
+
+function displayPrinterDetails(printerId) {
+ const printer = printers[printerId];
+ if (!printer) return;
+
+ // Set printer header info
+ document.getElementById('printerName').textContent = printer.name;
+ document.getElementById('printerType').textContent = `${printer.brand} ${printer.model}`;
+ document.getElementById('printerIcon').src = printer.icon || window.DEFAULT_PRINTER_ICON;
+
+ // Set printer status
+ const statusEl = document.getElementById('printerStatus');
+ if (printer.status === 'connected') {
+ statusEl.innerHTML = ' Connected';
+
+ if (printer.machine_status) {
+ statusEl.innerHTML += ` - ${printer.machine_status}`;
+ }
+ } else {
+ statusEl.innerHTML = ' Disconnected';
+ }
+
+ // Create tabs
+ createTabs(printer);
+
+ // Initialize camera tab if printer supports camera
+ if (printer.supports_camera) {
+ createCameraTab(printer);
+ }
+}
+
+function logDebug(...args) {
+ if (DEBUG) {
+ console.log('[ChitUI Debug]', ...args);
+ }
+}
+
+function logError(...args) {
+ console.error('[ChitUI Error]', ...args);
+}
+
+// static/js/admin.js
+const uptimeEl = document.getElementById("uptime");
+const printersEl = document.getElementById("connectedPrinters");
+
+async function refreshStatus() {
+ let [health, printers] = await Promise.all([
+ fetch("/api/health").then(r => r.json()),
+ fetch("/api/printer/list").then(r => r.json())
+ ]);
+ // health.uptime is in seconds
+ let secs = health.uptime;
+ let d = Math.floor(secs/86400), h = Math.floor((secs%86400)/3600), m = Math.floor((secs%3600)/60);
+ uptimeEl.textContent = `${d}d ${h}h ${m}m`;
+ printersEl.textContent = Object.keys(printers).length;
+}
+
+// kick off, then every 30s
+refreshStatus();
+setInterval(refreshStatus, 30_000);
diff --git a/static/js/color-modes.js b/static/js/color-modes.js
new file mode 100644
index 0000000..971b853
--- /dev/null
+++ b/static/js/color-modes.js
@@ -0,0 +1,79 @@
+/**
+ * Theme toggle functionality for ChitUI
+ * Based on Bootstrap's color modes toggler
+ */
+(() => {
+ 'use strict';
+
+ const getStoredTheme = () => localStorage.getItem('theme');
+ const setStoredTheme = theme => localStorage.setItem('theme', theme);
+
+ const getPreferredTheme = () => {
+ const storedTheme = getStoredTheme();
+ if (storedTheme) {
+ return storedTheme;
+ }
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ };
+
+ const setTheme = theme => {
+ if (theme === 'auto') {
+ document.documentElement.setAttribute('data-bs-theme',
+ window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+ } else {
+ document.documentElement.setAttribute('data-bs-theme', theme);
+ }
+ };
+
+ setTheme(getPreferredTheme());
+
+ const showActiveTheme = (theme, focus = false) => {
+ const themeSwitcher = document.querySelector('#bd-theme');
+ if (!themeSwitcher) {
+ return;
+ }
+
+ const themeSwitcherText = document.querySelector('#bd-theme-text');
+ const activeThemeIcon = document.querySelector('.theme-icon-active use');
+ const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`);
+ const svgOfActiveBtn = btnToActive.querySelector('svg use').getAttribute('href');
+
+ document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
+ element.classList.remove('active');
+ element.setAttribute('aria-pressed', 'false');
+ });
+
+ btnToActive.classList.add('active');
+ btnToActive.setAttribute('aria-pressed', 'true');
+ activeThemeIcon.setAttribute('href', svgOfActiveBtn);
+
+ if (themeSwitcherText) {
+ const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`;
+ themeSwitcher.setAttribute('aria-label', themeSwitcherLabel);
+ }
+
+ if (focus) {
+ themeSwitcher.focus();
+ }
+ };
+
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ const storedTheme = getStoredTheme();
+ if (storedTheme !== 'light' && storedTheme !== 'dark') {
+ setTheme(getPreferredTheme());
+ }
+ });
+
+ window.addEventListener('DOMContentLoaded', () => {
+ showActiveTheme(getPreferredTheme());
+
+ document.querySelectorAll('[data-bs-theme-value]').forEach(toggle => {
+ toggle.addEventListener('click', () => {
+ const theme = toggle.getAttribute('data-bs-theme-value');
+ setStoredTheme(theme);
+ setTheme(theme);
+ showActiveTheme(theme, true);
+ });
+ });
+ });
+})();
diff --git a/web/js/jquery-3.7.1.min.js b/static/js/jquery-3.7.1.min.js
similarity index 100%
rename from web/js/jquery-3.7.1.min.js
rename to static/js/jquery-3.7.1.min.js
diff --git a/web/js/jquery-3.7.1.min.map b/static/js/jquery-3.7.1.min.map
similarity index 100%
rename from web/js/jquery-3.7.1.min.map
rename to static/js/jquery-3.7.1.min.map
diff --git a/web/js/sdcp.js b/static/js/sdcp.js
similarity index 100%
rename from web/js/sdcp.js
rename to static/js/sdcp.js
diff --git a/web/js/socket.io.min.js b/static/js/socket.io.min.js
similarity index 100%
rename from web/js/socket.io.min.js
rename to static/js/socket.io.min.js
diff --git a/static/js/theme-toggle.js b/static/js/theme-toggle.js
new file mode 100644
index 0000000..a2a9ffa
--- /dev/null
+++ b/static/js/theme-toggle.js
@@ -0,0 +1,84 @@
+
+/**
+ * Theme toggle functionality for ChitUI
+ * Based on Bootstrap's color modes toggler
+ */
+(() => {
+ 'use strict';
+
+ const getStoredTheme = () => localStorage.getItem('theme');
+ const setStoredTheme = theme => localStorage.setItem('theme', theme);
+
+ const getPreferredTheme = () => {
+ const storedTheme = getStoredTheme();
+ if (storedTheme) {
+ return storedTheme;
+ }
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ };
+
+ const setTheme = theme => {
+ if (theme === 'auto') {
+ document.documentElement.setAttribute('data-bs-theme',
+ window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+ } else {
+ document.documentElement.setAttribute('data-bs-theme', theme);
+ }
+ };
+
+ setTheme(getPreferredTheme());
+
+ const showActiveTheme = (theme, focus = false) => {
+ const themeSwitcher = document.querySelector('#bd-theme');
+ if (!themeSwitcher) {
+ return;
+ }
+
+ const themeSwitcherText = document.querySelector('#bd-theme-text');
+ const activeThemeIcon = document.querySelector('.theme-icon-active use');
+ const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`);
+ if (!btnToActive) {
+ return;
+ }
+
+ const svgOfActiveBtn = btnToActive.querySelector('svg use').getAttribute('href');
+
+ document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
+ element.classList.remove('active');
+ element.setAttribute('aria-pressed', 'false');
+ });
+
+ btnToActive.classList.add('active');
+ btnToActive.setAttribute('aria-pressed', 'true');
+ activeThemeIcon.setAttribute('href', svgOfActiveBtn);
+
+ if (themeSwitcherText) {
+ const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`;
+ themeSwitcher.setAttribute('aria-label', themeSwitcherLabel);
+ }
+
+ if (focus) {
+ themeSwitcher.focus();
+ }
+ };
+
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ const storedTheme = getStoredTheme();
+ if (storedTheme !== 'light' && storedTheme !== 'dark') {
+ setTheme(getPreferredTheme());
+ }
+ });
+
+ window.addEventListener('DOMContentLoaded', () => {
+ showActiveTheme(getPreferredTheme());
+
+ document.querySelectorAll('[data-bs-theme-value]').forEach(toggle => {
+ toggle.addEventListener('click', () => {
+ const theme = toggle.getAttribute('data-bs-theme-value');
+ setStoredTheme(theme);
+ setTheme(theme);
+ showActiveTheme(theme, true);
+ });
+ });
+ });
+})();
\ No newline at end of file
diff --git a/web/assets/site.webmanifest b/static/site.webmanifest
similarity index 100%
rename from web/assets/site.webmanifest
rename to static/site.webmanifest
diff --git a/static/web-app-manifest-192x192.png b/static/web-app-manifest-192x192.png
new file mode 100644
index 0000000..03de22c
Binary files /dev/null and b/static/web-app-manifest-192x192.png differ
diff --git a/static/web-app-manifest-512x512.png b/static/web-app-manifest-512x512.png
new file mode 100644
index 0000000..602ce94
Binary files /dev/null and b/static/web-app-manifest-512x512.png differ
diff --git a/templates/admin.html b/templates/admin.html
new file mode 100644
index 0000000..5a11cf1
--- /dev/null
+++ b/templates/admin.html
@@ -0,0 +1,456 @@
+
+
+
+
+
+
+ ChitUI - Admin Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+ {% for category, message in messages %}
+
+ {{ message }}
+
+
+ {% endfor %}
+ {% endif %}
+ {% endwith %}
+
+
+
+
+
User Management
+
+
+
+
+
+
+
+
+
+ | Username |
+ Role |
+ Actions |
+
+
+
+ {% for user in users %}
+
+ | {{ user.username }} |
+
+
+ {{ user.role }}
+
+ |
+
+
+
+ {% if user.id != current_user.id %}
+
+ {% endif %}
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+ System Settings
+
+
+
+
+
+
+
+ -
+ Version
+ 1.0.0
+
+ -
+ Python Version
+ 3.12
+
+ -
+ Uptime
+ 0d 0h 0m
+
+ -
+ Connected Printers
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+ System Logs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to delete user ?
+
This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..9040c37
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,432 @@
+
+
+
+
+
+
+
+ ChitUI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Printers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Printer]()
+
+
+
+
Select a printer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Searching for compatible 3D printers on your network...
+
+ Starting scan...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Please confirm that you want to
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Printer]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ filename.ctb
+ Uploading
+
+
To: Printer Name
+
+
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..d61acd6
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+ ChitUI - Login
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/viewer.html b/templates/viewer.html
new file mode 100644
index 0000000..3b8d520
--- /dev/null
+++ b/templates/viewer.html
@@ -0,0 +1,229 @@
+
+
+
+
+
+ 3D Model Viewer with Multi-Model & Build-Plate Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/css/chitui.css b/web/css/chitui.css
deleted file mode 100644
index 335bb47..0000000
--- a/web/css/chitui.css
+++ /dev/null
@@ -1,72 +0,0 @@
-body {
- min-height: 100vh;
- min-height: -webkit-fill-available;
-}
-
-html {
- height: -webkit-fill-available;
-}
-
-main {
- height: 100vh;
- height: -webkit-fill-available;
- max-height: 100vh;
- overflow-x: auto;
- overflow-y: hidden;
-}
-
-.dropdown-toggle { outline: 0; }
-
-
-[data-bs-theme="dark"] .btn-toggle::before {
- content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%28255,255,255,.5%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");
-}
-
-
-.bd-placeholder-img {
- font-size: 1.125rem;
- text-anchor: middle;
- -webkit-user-select: none;
- -moz-user-select: none;
- user-select: none;
-}
-
-@media (min-width: 768px) {
- .bd-placeholder-img-lg {
- font-size: 3.5rem;
- }
-}
-
-.bi {
- vertical-align: -.125em;
- fill: currentColor;
-}
-
-.btn-bd-primary {
- --bd-violet-bg: #712cf9;
- --bd-violet-rgb: 112.520718, 44.062154, 249.437846;
-
- --bs-btn-font-weight: 600;
- --bs-btn-color: var(--bs-white);
- --bs-btn-bg: var(--bd-violet-bg);
- --bs-btn-border-color: var(--bd-violet-bg);
- --bs-btn-hover-color: var(--bs-white);
- --bs-btn-hover-bg: #6528e0;
- --bs-btn-hover-border-color: #6528e0;
- --bs-btn-focus-shadow-rgb: var(--bd-violet-rgb);
- --bs-btn-active-color: var(--bs-btn-hover-color);
- --bs-btn-active-bg: #5a23c8;
- --bs-btn-active-border-color: #5a23c8;
-}
-
-.bd-mode-toggle {
- z-index: 1500;
-}
-
-.bd-mode-toggle .dropdown-menu .active .bi {
- display: block !important;
-}
-
-.scrollarea {
- overflow-y: auto;
-}
diff --git a/web/index.html b/web/index.html
deleted file mode 100644
index 024278d..0000000
--- a/web/index.html
+++ /dev/null
@@ -1,198 +0,0 @@
-
-
-
-
-
-
-
-
-
- ChitUI
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![...]()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- File Upload
- 11 mins ago
-
-
-
Success!
-
-
-
-
-
-
-
- Please confirm that you want to
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![]()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/js/chitui.js b/web/js/chitui.js
deleted file mode 100644
index eb51434..0000000
--- a/web/js/chitui.js
+++ /dev/null
@@ -1,395 +0,0 @@
-const socket = io();
-var websockets = []
-var printers = {}
-var currentPrinter = null
-var progress = null
-
-socket.on("connect", () => {
- console.log('socket.io connected: ' + socket.id);
- setServerStatus(true)
-});
-
-socket.on("disconnect", () => {
- console.log("socket.io disconnected"); // undefined
- setServerStatus(false)
-});
-
-socket.on("printers", (data) => {
- console.log(JSON.stringify(data))
- printers = data
- $("#printersList").empty()
- addPrinters(data)
-});
-
-socket.on("printer_response", (data) => {
- switch (data.Data.Cmd) {
- case SDCP_CMD_STATUS:
- case SDCP_CMD_ATTRIBUTES:
- break
- case SDCP_CMD_RETRIEVE_FILE_LIST:
- handle_printer_files(data)
- break
- case SDCP_CMD_BATCH_DELETE_FILES:
- modalConfirm.hide()
- break
- case SDCP_CMD_START_PRINTING:
- modalConfirm.hide()
- break
- default:
- console.log(data)
- break
- }
-});
-
-socket.on("printer_error", (data) => {
- console.log("=== ERROR ===")
- console.log(data)
- alert("Error Code:" + data.Data.Data.ErrorCode)
-});
-
-socket.on("printer_notice", (data) => {
- console.log("=== NOTICE ===")
- console.log(data)
- alert("Notice:" + data.Data.Data.Message)
-});
-
-socket.on("printer_status", (data) => {
- handle_printer_status(data)
-});
-
-socket.on("printer_attributes", (data) => {
- handle_printer_attributes(data)
-});
-
-
-function handle_printer_status(data) {
- //console.log(JSON.stringify(data))
- if (!printers[data.MainboardID].hasOwnProperty('status')) {
- printers[data.MainboardID]['status'] = {}
- }
- var filter = ['CurrentStatus', 'PrintScreen', 'ReleaseFilm', 'TempOfUVLED', 'TimeLapseStatus', 'PrintInfo']
- $.each(data.Status, function (key, val) {
- if (filter.includes(key)) {
- if (val.length == 1) {
- val = val[0]
- }
- printers[data.MainboardID]['status'][key] = val
- }
- })
- printer_status = printers[data.MainboardID]['status']
- // update file list on status change from UNKNOWN_8 to Idle
- if (typeof printer_status['PreviousStatus'] !== undefined
- && printer_status['PreviousStatus'] == SDCP_MACHINE_STATUS_UNKNOWN_8
- && printer_status['CurrentStatus'] == SDCP_MACHINE_STATUS_IDLE) {
- socket.emit("printer_files", { id: data.MainboardID, url: '/local' })
- }
- printers[data.MainboardID]['status']['PreviousStatus'] = printer_status['CurrentStatus']
- updatePrinterStatus(data)
- createTable('Status', data.Status)
- if (data.Status.CurrentStatus.includes(1)) {
- createTable('Print', data.Status.PrintInfo)
- }
-}
-
-function handle_printer_attributes(data) {
- console.log(data)
- if (!printers[data.MainboardID].hasOwnProperty('attributes')) {
- printers[data.MainboardID]['attributes'] = {}
- }
- var filter = ['Resolution', 'XYZsize', 'NumberOfVideoStreamConnected', 'MaximumVideoStreamAllowed', 'UsbDiskStatus', 'Capabilities', 'SupportFileType', 'DevicesStatus', 'ReleaseFilmMax', 'CameraStatus', 'RemainingMemory', 'TLPNoCapPos', 'TLPStartCapPos', 'TLPInterLayers']
- $.each(data.Attributes, function (key, val) {
- if (filter.includes(key)) {
- printers[data.MainboardID]['attributes'][key] = val
- }
- })
- createTable('Attributes', data.Attributes)
-}
-
-function handle_printer_files(data) {
- var id = data.Data.MainboardID
- files = []
- if (printers[id]['files'] !== undefined) {
- files = printers[id]['files']
- }
- $.each(data.Data.Data.FileList, function (i, f) {
- if (f.type === 0) {
- getPrinterFiles(id, f.name)
- } else {
- if (!files.includes(f.name)) {
- files.push(f.name)
- }
- }
- })
- printers[id]['files'] = files
- createTable('Files', files)
- addFileOptions()
-}
-
-function addPrinters(printers) {
- $.each(printers, function (id, printer) {
- var template = $("#tmplPrintersListItem").html()
- var item = $(template)
- var printerIcon = (printer.brand + '_' + printer.model).split(" ").join("").toLowerCase()
- item.attr('id', 'printer_' + id)
- item.attr("data-connection-id", printer.connection)
- item.attr("data-printer-id", id)
- item.find(".printerName").text(printer.name)
- item.find(".printerType").text(printer.brand + ' ' + printer.model)
- item.find(".printerIcon").attr("src", 'img/' + printerIcon + '.webp')
- item.on('click', function () {
- // $.each($('.printerListItem'), function () {
- // $(this).removeClass('active')
- // })
- // $(this).addClass('active')
- showPrinter($(this).data('printer-id'))
- })
- $("#printersList").append(item)
- socket.emit("printer_info", { id: id })
- });
-}
-
-function showPrinter(id) {
- //console.log(JSON.stringify(printers[id]))
- currentPrinter = id
- var p = printers[id]
- var printerIcon = (p.brand + '_' + p.model).split(" ").join("").toLowerCase()
- $('#printerName').text(p.name)
- $('#printerType').text(p.brand + ' ' + p.model)
- $("#printerIcon").attr("src", 'img/' + printerIcon + '.webp')
-
- createTable('Status', p.status, true)
- createTable('Attributes', p.attributes)
- createTable('Print', p.status.PrintInfo)
-
- // only get files once
- if (printers[id]['files'] == undefined) {
- getPrinterFiles(id, '/local')
- if (p.attributes.UsbDiskStatus == 1) {
- getPrinterFiles(id, '/usb')
- }
- }
-
- $('#uploadPrinter').val(id)
-}
-
-function createTable(name, data, active = false) {
- if ($('#tab-' + name).length == 0) {
- var tTab = $("#tmplNavTab").html()
- var tab = $(tTab)
- tab.find('button').attr('id', 'tab-' + name)
- tab.find('button').attr('data-bs-target', '#tab' + name)
- tab.find('button').text(name)
- if (active) {
- tab.find('button').addClass('active')
- }
- $('#navTabs').append(tab)
- }
-
- if ($('#tab' + name).length == 0) {
- var tPane = $("#tmplNavPane").html()
- var pane = $(tPane)
- pane.attr('id', 'tab' + name)
- pane.find('tbody').attr('id', 'table' + name)
- if (active) {
- pane.addClass('active')
- }
- $('#navPanes').append(pane)
- }
- fillTable(name, data)
-}
-
-function fillTable(table, data) {
- var t = $('#table' + table)
- t.empty()
- $.each(data, function (key, val) {
- if (typeof val === 'object') {
- val = JSON.stringify(val)
- }
- var row = $('| ' + key + ' | ' + val + ' |
')
- t.append(row)
- })
-}
-
-function getPrinterFiles(id, url) {
- socket.emit("printer_files", { id: id, url: url })
-}
-
-function addFileOptions() {
- $('#tableFiles .fieldValue').each(function () {
- var file = $(this).text()
- var options = $('')
- $(this).append(options)
- $(this).parent().attr('data-file', file)
- })
- $('.fileOption').on('click', function (e) {
- var action = $(this).data('action')
- var file = $(this).data('file')
- $('#modalConfirmTitle').text('Confirm ' + action)
- $('#modalConfirmAction').text(action)
- $('#modalConfirmValue').text(file)
- $('#btnConfirm').data('action', action).data('value', file)
- modalConfirm.show()
- })
-}
-
-function updatePrinterStatus(data) {
- var info = $('#printer_' + data.MainboardID).find('.printerInfo')
- switch (data.Status.CurrentStatus[0]) {
- case SDCP_MACHINE_STATUS_IDLE:
- info.text("Idle")
- updatePrinterStatusIcon(data.MainboardID, "success", false)
- break
- case SDCP_MACHINE_STATUS_PRINTING:
- info.text("Printing")
- updatePrinterStatusIcon(data.MainboardID, "success", true)
- break
- case SDCP_MACHINE_STATUS_FILE_TRANSFERRING:
- info.text("File Transfer")
- updatePrinterStatusIcon(data.MainboardID, "warning", true)
- break
- case SDCP_MACHINE_STATUS_EXPOSURE_TESTING:
- info.text("Exposure Test")
- updatePrinterStatusIcon(data.MainboardID, "info", true)
- break
- case SDCP_MACHINE_STATUS_DEVICES_TESTING:
- info.text("Devices Self-Test")
- updatePrinterStatusIcon(data.MainboardID, "warning", true)
- break
- case SDCP_MACHINE_STATUS_UNKNOWN_8:
- info.text("UNKNOWN STATUS")
- updatePrinterStatusIcon(data.MainboardID, "info", true)
- break
- default:
- break
- }
-}
-
-function updatePrinterStatusIcon(id, style, spinner) {
- var el = 'printerStatus'
- if (spinner) {
- el = 'printerSpinner'
- $('.printerStatus').addClass('visually-hidden')
- $('.printerSpinner').removeClass('visually-hidden')
- } else {
- $('.printerStatus').removeClass('visually-hidden')
- $('.printerSpinner').addClass('visually-hidden')
- }
- var status = $('#printer_' + id).find('.' + el)
- status.removeClass(function (index, css) {
- return (css.match(/\btext-\S+/g) || []).join(' ');
- }).addClass("text-" + style);
- status.find('i').removeClass().addClass('bi-circle-fill')
-}
-
-function setServerStatus(online) {
- serverStatus = $('.serverStatus')
- if (online) {
- serverStatus.removeClass('bi-cloud text-danger').addClass('bi-cloud-check-fill')
- } else {
- serverStatus.removeClass('bi-cloud-check-fill').addClass('bi-cloud text-danger')
- }
-}
-
-$('#btnUpload').on('click', function () {
- uploadFile()
-});
-
-function uploadFile() {
- var req = $.ajax({
- url: '/upload',
- type: 'POST',
- data: new FormData($('#formUpload')[0]),
- // Tell jQuery not to process data or worry about content-type
- // You *must* include these options!
- cache: false,
- contentType: false,
- processData: false,
- // Custom XMLHttpRequest
- xhr: function () {
- var myXhr = $.ajaxSettings.xhr();
- if (myXhr.upload) {
- // For handling the progress of the upload
- myXhr.upload.addEventListener('progress', function (e) {
- if (e.lengthComputable) {
- var percent = Math.floor(e.loaded / e.total * 100);
- $('#progressUpload').text('Upload to ChitUI: ' + percent + '%').css('width', percent + '%');
- if (percent == 100) {
- setTimeout(function () {
- fileTransferProgress()
- }, 1000)
- }
- }
- }, false);
- }
- return myXhr;
- }
- })
- req.done(function (data) {
- $('#uploadFile').val('')
- $("#toastUpload").show()
- setTimeout(function () {
- $("#toastUpload").hide()
- }, 3000)
- })
- req.fail(function (data) {
- alert(data.responseJSON.msg)
- })
- req.always(function () {
- })
-}
-
-$('.serverStatus').on('mouseenter', function (e) {
- if ($(this).hasClass('bi-cloud-check-fill')) {
- $(this).removeClass('bi-cloud-check-fill').addClass('bi-cloud-plus text-primary')
- }
-});
-
-$('.serverStatus').on('mouseleave', function (e) {
- $(this).removeClass('bi-cloud-plus text-primary').addClass('bi-cloud-check-fill')
-});
-
-$('.serverStatus').on('click', function (e) {
- socket.emit("printers", "{}")
-});
-
-$('#toastUpload .btn-close').on('click', function (e) {
- $("#toastUpload").hide()
-});
-
-var modalConfirm;
-$(document).ready(function () {
- modalConfirm = new bootstrap.Modal($('#modalConfirm'), {})
-});
-
-$('#btnConfirm').on('click', function () {
- socket.emit('action_' + $(this).data('action'), { id: currentPrinter, data: $(this).data('value') })
- $('#tableFiles tr[data-file="' + $(this).data('value') + '"]').remove()
-});
-
-function fileTransferProgress() {
- $('#progressUpload').addClass('progress-bar-striped progress-bar-animated')
- progress = new EventSource('/progress');
- progress.onmessage = function (event) {
- if (event.data > 0) {
- $('#progressUpload').text('Upload to printer: ' + event.data + '%').css('width', event.data + '%').addClass('text-bg-warning');
- }
- if (event.data == 100) {
- setTimeout(function () {
- $('#progressUpload').text('0%').css('width', '0%');
- setTimeout(function () {
- $('#progressUpload').removeClass('progress-bar-striped progress-bar-animated text-bg-warning')
- }, 1000)
- }, 1000)
- progress.close()
- }
- };
-}
-
-/* global bootstrap: false */
-(() => {
- 'use strict'
- const tooltipTriggerList = Array.from(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
- tooltipTriggerList.forEach(tooltipTriggerEl => {
- new bootstrap.Tooltip(tooltipTriggerEl)
- })
-})()
diff --git a/web/js/color-modes.js b/web/js/color-modes.js
deleted file mode 100644
index 8a0dabf..0000000
--- a/web/js/color-modes.js
+++ /dev/null
@@ -1,80 +0,0 @@
-/*!
- * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
- * Copyright 2011-2024 The Bootstrap Authors
- * Licensed under the Creative Commons Attribution 3.0 Unported License.
- */
-
-(() => {
- 'use strict'
-
- const getStoredTheme = () => localStorage.getItem('theme')
- const setStoredTheme = theme => localStorage.setItem('theme', theme)
-
- const getPreferredTheme = () => {
- const storedTheme = getStoredTheme()
- if (storedTheme) {
- return storedTheme
- }
-
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
- }
-
- const setTheme = theme => {
- if (theme === 'auto') {
- document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'))
- } else {
- document.documentElement.setAttribute('data-bs-theme', theme)
- }
- }
-
- setTheme(getPreferredTheme())
-
- const showActiveTheme = (theme, focus = false) => {
- const themeSwitcher = document.querySelector('#bd-theme')
-
- if (!themeSwitcher) {
- return
- }
-
- const themeSwitcherText = document.querySelector('#bd-theme-text')
- const activeThemeIcon = document.querySelector('.theme-icon-active use')
- const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
- const svgOfActiveBtn = btnToActive.querySelector('svg use').getAttribute('href')
-
- document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
- element.classList.remove('active')
- element.setAttribute('aria-pressed', 'false')
- })
-
- btnToActive.classList.add('active')
- btnToActive.setAttribute('aria-pressed', 'true')
- activeThemeIcon.setAttribute('href', svgOfActiveBtn)
- const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
- themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
-
- if (focus) {
- themeSwitcher.focus()
- }
- }
-
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
- const storedTheme = getStoredTheme()
- if (storedTheme !== 'light' && storedTheme !== 'dark') {
- setTheme(getPreferredTheme())
- }
- })
-
- window.addEventListener('DOMContentLoaded', () => {
- showActiveTheme(getPreferredTheme())
-
- document.querySelectorAll('[data-bs-theme-value]')
- .forEach(toggle => {
- toggle.addEventListener('click', () => {
- const theme = toggle.getAttribute('data-bs-theme-value')
- setStoredTheme(theme)
- setTheme(theme)
- showActiveTheme(theme, true)
- })
- })
- })
-})()