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 +![Printer List](https://github.com/user-attachments/assets/9077c509-8605-45e3-84ce-fd214f875b49) +![File Management](https://github.com/user-attachments/assets/b7696317-efa1-403d-b411-40d320f0ad1e) +![Print Status](https://github.com/user-attachments/assets/df432a33-8e3c-43f1-a9ee-82a39febde0a) +![Info](https://github.com/user-attachments/assets/7a7aadc4-52c0-4999-9fcf-8d0e5031d221) +![Admin](https://github.com/user-attachments/assets/a89df49b-ad1e-40b9-9527-9e2d91e7985b) + + +## πŸš€ 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 = ` +
+
Files
+
+
+
+
+
+
+ Loading files... +
+
+ `; + 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 = ` +
+
+ Loading files... +
+ `; + }); + } 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 +
+
+
+
Camera Controls
+
+
+
+
+ + +
+
+
+
+ +
+ 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 %} + + {% endfor %} + {% endif %} + {% endwith %} + + +
+
+

User Management

+ +
+ +
+
+
+ + + + + + + + + + {% for user in users %} + + + + + + {% endfor %} + +
UsernameRoleActions
{{ user.username }} + + {{ user.role }} + + +
+ + {% if user.id != current_user.id %} + + {% endif %} +
+
+
+
+
+
+ + +
+

System Settings

+
+
+
+
+
Network Settings
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+
+
System Information
+
+
+
    +
  • + Version + 1.0.0 +
  • +
  • + Python Version + 3.12 +
  • +
  • + Uptime + 0d 0h 0m +
  • +
  • + Connected Printers + 0 +
  • +
+
+ +
+
+
+
+
+
+ + +
+

System Logs

+
+
+
Application Logs
+
+ +
+
+
+
+
Loading logs...
+
+
+ + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + \ 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

+

+

+ +

+
+
+
+
+
+
+ + +
+
+ +
+ +
+ + +
+
+ File Upload +
+
+
+
+ + + +
+
+
0%
+
+ +
+
+
+
+
+ + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ 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 + + + + + + + + + + +
+ +
+
Printer Settings
+
+ + +
+
+
Model Controls
+
+ + +
+
+ +
+ + + +
+
+
+ + +
+
+ +
+
+
Camera
+
+ +
+
+ + +
+ +
+
+ + + + 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 -
-
-
-
- - - -
-
0%
-
- -
-
-
-
- - - - - -
- -
- - - - - - - - - - - - - - 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) - }) - }) - }) -})()