diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90f8fbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6ba6406 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run Commands +- Create environment: `mamba create -n openhsi python==3.10 openhsi flask` +- Activate environment: `mamba activate openhsi` +- Run server: `python server.py` +- Run as service: `sudo systemctl start openhsi-flask.service` + +## Code Style Guidelines +- Indentation: 4 spaces +- Line length: ~88 characters (Black default) +- Imports: Group by source (stdlib, third-party, local); use parenthesized imports +- Naming: Classes: PascalCase, Functions/vars: snake_case, Constants: UPPER_SNAKE_CASE +- Error handling: Use try/except with specific exceptions, log errors with app.logger +- Types: Type annotations not currently used +- Documentation: Docstrings and Flask-RestX for API endpoints +- API patterns: Use Flask-RestX models for validation and documentation + +## Project Structure +- Flask-based web controller for OpenHSI cameras +- RESTful API with Swagger documentation at `/api/apidocs` +- Static files in `/static` and templates in `/templates` \ No newline at end of file diff --git a/README.md b/README.md index 9b0278e..fe17e3e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,309 @@ -## Simple Web Controller for OpenHSI +# Simple Web Controller for OpenHSI -An example simple webinterface to drive an OpenHSI camera. +A Flask-based web interface for controlling and managing OpenHSI hyperspectral cameras. This package provides a complete web application with RESTful API, real-time capture monitoring, and systemd service integration. -![Screen Cap for Web interface](assets/screencap.png) \ No newline at end of file +![Screen Cap for Web interface](assets/screencap.png) + +## Features + +- **Web Interface**: Modern Bootstrap-based UI for camera control +- **RESTful API**: Complete API with Swagger documentation +- **Real-time Monitoring**: Live capture progress and status updates +- **File Management**: Browse, view, download, and delete captured files +- **Configuration Management**: YAML-based configuration system +- **Systemd Integration**: Easy service installation and management +- **Installable Package**: Standard Python package with CLI entry point + +## Installation + +### Prerequisites + +- Python 3.10+ +- OpenHSI camera drivers and calibration files +- Conda/Mamba environment (recommended) + +### Install from Source + +```bash +# Clone the repository +git clone +cd simple-web-controller + +# Create and activate conda environment +mamba create -n openhsi python==3.10 openhsi flask +mamba activate openhsi + +# Install the package in development mode +pip install -e . +``` + +### Install from PyPI (when available) + +```bash +pip install simple-web-controller +``` + +## Configuration + +Create a `config.yaml` file with your camera and server settings: + +```yaml +# Camera calibration paths +camera: + json_path: "/path/to/camera_settings.json" + cal_path: "/path/to/calibration.nc" + default_settings: + n_lines: 512 + exposure_ms: 10 + processing_lvl: -1 + +# File operations +files: + data_directory: "/data" + default_save_directory: "/data" + +# Server settings +server: + debug: false + threaded: true + +# Logging +logging: + max_log_messages: 100 +``` + +## Usage + +### Running the Server + +```bash +# Run with default config.yaml +simple-web-controller + +# Run with custom config +simple-web-controller --config /path/to/config.yaml + +# Run on different host/port +simple-web-controller --host 0.0.0.0 --port 8080 + +# Enable debug mode +simple-web-controller --debug +``` + +### Web Interface + +Access the web interface at: +- **Main Interface**: `http://localhost:5000` +- **API Documentation**: `http://localhost:5000/api/apidocs` +- **File Browser**: `http://localhost:5000/browse/` + +### API Endpoints + +See dummy swagger ui https://openhsi.github.io/simple-web-controller/ + +## Systemd Service Management + +### Install Service + +```bash +# Install with default settings +simple-web-controller install-service + +# Install with custom configuration +simple-web-controller install-service \ + --config /opt/config.yaml \ + --working-directory /opt/simple-web-controller \ + --user openhsi \ + --host 0.0.0.0 \ + --port 5000 \ + --start +``` + +### Manage Service + +```bash +# Check service status +simple-web-controller service-status + +# Manual service control +sudo systemctl start simple-web-controller +sudo systemctl stop simple-web-controller +sudo systemctl restart simple-web-controller + +# View logs +sudo journalctl -u simple-web-controller -f + +# Uninstall service +simple-web-controller uninstall-service +``` + +## Nginx Reverse Proxy Setup + +For production deployments, it's recommended to run the application behind an nginx reverse proxy for better performance and security. this allows access via port 80 (i.e as standard web page, http://localhost). + +### Install Nginx + +```bash +# Ubuntu/Debian +sudo apt update +sudo apt install nginx + +# CentOS/RHEL +sudo yum install nginx +# or +sudo dnf install nginx +``` + +### Configure Nginx + +1. **Create nginx configuration file:** + +```bash +sudo nano /etc/nginx/sites-available/simple-web-controller +``` + +2. **Add the following configuration** (based on `assets/openhsi.ngnix`): + +```nginx +server { + listen 80; + server_name _; # Replace with your domain or IP address + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +3. **Enable the site:** + +```bash +# Create symbolic link to enable the site +sudo ln -s /etc/nginx/sites-available/simple-web-controller /etc/nginx/sites-enabled/ + +# Remove default site +rm -r /etc/nginx/sites-enabled/default + +# Test nginx configuration +sudo nginx -t + +# Restart nginx +sudo systemctl restart nginx +sudo systemctl enable nginx +``` + +### Production Configuration + +For production, configure the application to bind to localhost only: + +```bash +# Install service to bind to localhost +simple-web-controller install-service \ + --host 127.0.0.1 \ + --port 5000 \ + --start +``` + + +## Development + +### Project Structure + +``` +simple-web-controller/ +├── pyproject.toml # Package configuration +├── requirements.txt # Dependencies +├── config.yaml # Default configuration +├── simple_web_controller/ # Main package +│ ├── __init__.py # Package initialization +│ ├── cli.py # Command line interface +│ ├── server.py # Flask application +│ ├── systemd.py # Systemd service management +│ ├── templates/ # HTML templates +│ └── static/ # CSS/JS assets +├── assets/ # Documentation assets +└── docs/ # API documentation +``` + +### Development Setup + +```bash +# Install in development mode with dev dependencies +pip install -e ".[dev]" + +# Run tests (when available) +pytest + +# Code formatting +black simple_web_controller/ +flake8 simple_web_controller/ +``` + +## Camera Settings + +The interface supports both basic and advanced camera settings: + +### Basic Settings +- **n_lines**: Number of scan lines to capture +- **exposure_ms**: Exposure time in milliseconds +- **processing_lvl**: Data processing level (-1 to 4) + +### Advanced Settings +- **row_slice**: Range of rows to read from detector +- **resolution**: Image resolution [height, width] +- **fwhm_nm**: Spectral resolution in nanometers +- **luminance**: Luminance value for calibration +- **binxy**: Binning factors [x, y] +- **win_offset**: Window offset [x, y] +- **win_resolution**: Window resolution [width, height] +- **pixel_format**: Pixel format (Mono8, Mono12, Mono16) + +## File Management + +The web interface includes a built-in file browser for managing captured data: + +- **Browse directories** recursively from the data folder +- **View images** directly in browser +- **Download files** individually +- **Delete files** with confirmation +- **Navigation breadcrumbs** for easy directory traversal + +## Troubleshooting + +### Common Issues + +1. **Module not found errors**: Ensure you're in the correct conda environment +2. **Camera not detected**: Check calibration file paths in config.yaml +3. **Permission denied**: Run service installation with sudo +4. **Port already in use**: Change port with `--port` argument + +### Logs + +Check application logs for debugging: + +```bash +# Service logs +sudo journalctl -u simple-web-controller -f + +# Direct application logs (debug mode) +simple-web-controller --debug +``` + +## License + +See [LICENSE](LICENSE) file for details. + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make changes with tests +4. Submit a pull request + +## Support + +For issues and support, please check the documentation or create an issue in the repository. \ No newline at end of file diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..dfd63ba Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/openhsi-flask.service b/assets/openhsi-flask.service new file mode 100644 index 0000000..7a9df72 --- /dev/null +++ b/assets/openhsi-flask.service @@ -0,0 +1,13 @@ +[Unit] +Description=OpenHSI Flask Web Server +After=network.target + +[Service] +User=openhsi +WorkingDirectory=/home/openhsi/orlar/simple-web-controller +ExecStart=/home/openhsi/miniforge3/envs/openhsi/bin/python /home/openhsi/orlar/simple-web-controller/server.py +Restart=always +Environment=FLASK_ENV=production + +[Install] +WantedBy=multi-user.target diff --git a/assets/openhsi.ngnix b/assets/openhsi.ngnix new file mode 100644 index 0000000..b2393a9 --- /dev/null +++ b/assets/openhsi.ngnix @@ -0,0 +1,12 @@ +server { + listen 80; + server_name _; # Replace with your domain or IP address + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..1fa9975 --- /dev/null +++ b/config.yaml @@ -0,0 +1,26 @@ +# OpenHSI Flask Server Configuration + +# Camera calibration paths +camera: + json_path: "/home/openhsi/orlar/cals/OpenHSI-SAIL-orlar-01/OpenHSI-SAIL-orlar-01_settings_Mono8_bin1.json" + cal_path: "/home/openhsi/orlar/cals/OpenHSI-SAIL-orlar-01/OpenHSI-SAIL-orlar-01_calibration_Mono8_bin1.nc" + + # Default camera settings + default_settings: + n_lines: 512 + exposure_ms: 10 + processing_lvl: -1 + +# File operations +files: + data_directory: "/data" + default_save_directory: "/data" + +# Server settings +server: + debug: false + threaded: true + +# Logging +logging: + max_log_messages: 100 \ No newline at end of file diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..f16aca5 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,32 @@ + + + + + + + My New API + + + +
+ + + + \ No newline at end of file diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..3774dd1 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,80 @@ +![[assets/logo.png]] + +## Install dependancies + +### Miniforge +Download and install miniforge. +`wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"` +`bash Miniforge3-$(uname)-$(uname -m).sh` + +### Openhsi env +make python env and install openhsi+depenacies (match python version to that required by FLIR/Spinaker) +`mamba create -n openhsi python==3.10 openhsi flask` +`mamba activate openhsi` + +The rest assume you have openhsi env activated etc. + +### # Spinnaker SDK (FLIR camera, see openhsi docs for other cameras) +Download SDK. +https://www.teledynevisionsolutions.com/products/spinnaker-sdk/?model=Spinnaker%20SDK&vertical=machine%20vision&segment=iis + +#### Spinnaker SDK 4.2.0.46 for Ubuntu 22.04 (January 10, 2025) +https://flir.netx.net/file/asset/68772/original/attachment - 64-bit ARM SDK +https://flir.netx.net/file/asset/68774/original/attachment - Python 3.10 aarch64 + +`wget -O spinnaker_python-4.2.0.46-cp310-cp310-linux_aarch64-22.04.tar.gz https://flir.netx.net/file/asset/68774/original/attachment +`wget -O spinnaker-4.2.0.46-arm64-22.04-pkg.tar.gz https://flir.netx.net/file/asset/68772/original/attachment` + +`tar -xvzf spinnaker_python-4.2.0.46-cp310-cp310-linux_aarch64-22.04.tar.gz` +`tar -xvzf spinnaker-4.2.0.46-arm64-22.04-pkg.tar.gz` + +`sudo apt-get install libusb-1.0-0 qtbase5-dev qtchooser qt5-qmake qtbase5-dev-tools` +`sudo sh install_spinnaker_arm.sh` + +Answer yes to all quesitons. At "Adding new members to usergroup flirimaging", and any users on system that need to access camera., eg. *openhsi* user. + +`pip install spinnaker_python-4.2.0.46-cp310-cp310-linux_aarch64.whl --no-deps` +`pip install simple-pyspin --no-deps` + +## Install OpenHSI Orlar code +Clone or download this repo. + + +#### setup Systemd auto start +from repo folder + +`cp assets/openhsi-flask.service /etc/systemd/system/openhsi-flask.service ` + +`sudo systemctl daemon-reload` +`sudo systemctl enable openhsi-flask.service` +`sudo systemctl start openhsi-flask.service` + + +#### ngnix port 80 proxy +Setup ngnix proxy so interface can accessed via browser without port. + +`sudo apt update` +`sudo apt install nginx` + +`sudo rm /etc/nginx/sites-enabled/default` +`sudp cp assets//openhsi.ngnix /etc/nginx/sites-available/openhsi` +`sudo ln -s /etc/nginx/sites-available/openhsi /etc/nginx/sites-enabled/` + +`sudo nginx -t` +`sudo systemctl reload nginx` + + +## Setup Tailscale (FOR REMOTE ACCESS) +Depending on use case and deployment, it may be sensible to setup a remote access system, such as tailscale.com + + + + + + + + + + + + diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..c7ccaaa --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,503 @@ +{ + "swagger": "2.0", + "basePath": "/api", + "paths": { + "/capture": { + "post": { + "responses": { + "200": { + "description": "Capture started or already in progress" + } + }, + "summary": "Start the image capture process", + "operationId": "post_capture", + "tags": [ + "default" + ] + } + }, + "/delete/{filename}": { + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "type": "string" + } + ], + "delete": { + "responses": { + "500": { + "description": "Error occurred while deleting file" + }, + "404": { + "description": "File not found" + }, + "403": { + "description": "Forbidden - Cannot delete outside data directory" + }, + "200": { + "description": "File deleted successfully" + } + }, + "summary": "Delete a file from the data directory", + "operationId": "delete_delete_file", + "parameters": [ + { + "name": "filename", + "in": "query", + "required": true, + "type": "string", + "description": "The file path relative to the data directory" + } + ], + "tags": [ + "default" + ] + } + }, + "/download/{filename}": { + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "type": "string" + } + ], + "get": { + "responses": { + "404": { + "description": "File not found" + }, + "200": { + "description": "File sent as attachment" + } + }, + "summary": "Download a file from the data directory", + "operationId": "get_download", + "parameters": [ + { + "name": "filename", + "in": "query", + "required": true, + "type": "string", + "description": "The file path relative to the data directory" + } + ], + "tags": [ + "default" + ] + } + }, + "/file_list": { + "get": { + "responses": { + "404": { + "description": "Directory not found" + }, + "403": { + "description": "Forbidden - Cannot access directory outside data directory" + }, + "200": { + "description": "File list retrieved successfully" + } + }, + "summary": "Get a list of all files in the specified directory", + "operationId": "get_file_list", + "parameters": [ + { + "required": false, + "in": "query", + "description": "The folder path to list (relative to data directory)", + "name": "folder", + "type": "string" + } + ], + "tags": [ + "default" + ] + } + }, + "/logs": { + "get": { + "responses": { + "200": { + "description": "Log messages retrieved successfully" + } + }, + "summary": "Retrieve the log messages", + "operationId": "get_log_messages", + "tags": [ + "default" + ] + }, + "delete": { + "responses": { + "200": { + "description": "Log messages cleared successfully" + } + }, + "summary": "Clear the log messages", + "operationId": "delete_log_messages", + "tags": [ + "default" + ] + } + }, + "/save": { + "post": { + "responses": { + "500": { + "description": "Error occurred while saving files" + }, + "200": { + "description": "Files saved successfully" + } + }, + "summary": "Save the captured files to a specified directory", + "operationId": "post_save_files", + "parameters": [ + { + "name": "payload", + "required": true, + "in": "body", + "schema": { + "$ref": "#/definitions/Save" + } + } + ], + "tags": [ + "default" + ] + } + }, + "/show": { + "get": { + "responses": { + "204": { + "description": "No Content \u2013 capture not finished or image generation error" + }, + "200": { + "description": "Image retrieved successfully" + } + }, + "summary": "Retrieve the captured image as a PNG file with display options", + "operationId": "get_show_image", + "parameters": [ + { + "type": "integer", + "in": "query", + "description": "Contrast stretch percentage", + "name": "stretch" + }, + { + "type": "string", + "in": "query", + "description": "Band to display (rgb, red, green, blue, nir)", + "name": "band" + }, + { + "type": "boolean", + "in": "query", + "description": "Apply robust contrast stretching", + "name": "robust" + }, + { + "type": "boolean", + "in": "query", + "description": "Apply histogram equalization", + "name": "hist_eq" + } + ], + "tags": [ + "default" + ] + } + }, + "/status": { + "get": { + "responses": { + "200": { + "description": "Status retrieved successfully" + } + }, + "summary": "Retrieve the current capture status along with progress details", + "operationId": "get_status", + "tags": [ + "default" + ] + } + }, + "/update_settings": { + "post": { + "responses": { + "500": { + "description": "Internal error while updating settings" + }, + "400": { + "description": "Invalid input" + }, + "200": { + "description": "Settings updated successfully" + } + }, + "summary": "Update camera settings (both basic and advanced)", + "description": "This endpoint handles both the basic settings (n_lines, exposure_ms, processing_lvl)\nand the advanced detailed settings for the camera.\n\nAdvanced settings include:\n- row_slice: Range of rows to read from detector [start, end]\n- resolution: Image resolution [height, width]\n- fwhm_nm: Full Width at Half Maximum (spectral resolution) in nanometers\n- exposure_ms: Exposure time in milliseconds\n- luminance: Luminance value for calibration\n- binxy: Binning factors [x, y]\n- win_offset: Window offset [x, y]\n- win_resolution: Window resolution [width, height]\n- pixel_format: Pixel format (Mono8, Mono12, or Mono16)", + "operationId": "post_update_settings", + "parameters": [ + { + "name": "payload", + "required": true, + "in": "body", + "schema": { + "$ref": "#/definitions/FullSettings" + } + }, + { + "description": "Number of scan lines to capture", + "name": "n_lines", + "type": "string", + "in": "query" + }, + { + "description": "Exposure time in milliseconds", + "name": "exposure_ms", + "type": "string", + "in": "query" + }, + { + "description": "Processing level (-1 to 4)", + "name": "processing_lvl", + "type": "string", + "in": "query" + }, + { + "description": "Range of rows to read from detector [start, end]", + "name": "row_slice", + "type": "string", + "in": "query" + }, + { + "description": "Image resolution [height, width]", + "name": "resolution", + "type": "string", + "in": "query" + }, + { + "description": "Full Width at Half Maximum (spectral resolution) in nanometers", + "name": "fwhm_nm", + "type": "string", + "in": "query" + }, + { + "description": "Luminance value for calibration", + "name": "luminance", + "type": "string", + "in": "query" + }, + { + "description": "Binning factors [x, y]", + "name": "binxy", + "type": "string", + "in": "query" + }, + { + "description": "Window offset [x, y]", + "name": "win_offset", + "type": "string", + "in": "query" + }, + { + "description": "Window resolution [width, height]", + "name": "win_resolution", + "type": "string", + "in": "query" + }, + { + "description": "Pixel format (Mono8, Mono12, or Mono16)", + "name": "pixel_format", + "type": "string", + "in": "query" + } + ], + "tags": [ + "default" + ] + } + }, + "/view/{filename}": { + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "type": "string" + } + ], + "get": { + "responses": { + "404": { + "description": "File not found" + }, + "200": { + "description": "File sent for viewing" + } + }, + "summary": "View a file (especially images) in the browser without downloading", + "operationId": "get_view_file", + "parameters": [ + { + "name": "filename", + "in": "query", + "required": true, + "type": "string", + "description": "The file path relative to the data directory" + } + ], + "tags": [ + "default" + ] + } + } + }, + "info": { + "title": "OpenHSI Capture API", + "version": "1.1", + "description": "API for managing OpenHSI capture and file operations" + }, + "produces": [ + "application/json" + ], + "consumes": [ + "application/json" + ], + "tags": [ + { + "name": "default", + "description": "Default namespace" + } + ], + "definitions": { + "FullSettings": { + "allOf": [ + { + "$ref": "#/definitions/Settings" + }, + { + "properties": { + "row_slice": { + "type": "array", + "description": "Range of rows to read from detector [start, end]", + "example": [ + 8, + 913 + ], + "items": { + "type": "integer" + } + }, + "resolution": { + "type": "array", + "description": "Image resolution [height, width]", + "example": [ + 924, + 1240 + ], + "items": { + "type": "integer" + } + }, + "fwhm_nm": { + "type": "number", + "description": "Full Width at Half Maximum (spectral resolution) in nanometers", + "example": 4.0 + }, + "luminance": { + "type": "number", + "description": "Luminance value for calibration", + "example": 10000 + }, + "binxy": { + "type": "array", + "description": "Binning factors [x, y]", + "example": [ + 1, + 1 + ], + "items": { + "type": "integer" + } + }, + "win_offset": { + "type": "array", + "description": "Window offset [x, y]", + "example": [ + 96, + 200 + ], + "items": { + "type": "integer" + } + }, + "win_resolution": { + "type": "array", + "description": "Window resolution [width, height]", + "example": [ + 924, + 1240 + ], + "items": { + "type": "integer" + } + }, + "pixel_format": { + "type": "string", + "description": "Pixel format (Mono8, Mono12, or Mono16)", + "example": "Mono8" + } + }, + "type": "object" + } + ] + }, + "Settings": { + "properties": { + "n_lines": { + "type": "integer", + "description": "Number of lines", + "example": 512 + }, + "exposure_ms": { + "type": "number", + "description": "Exposure time in milliseconds", + "example": 10.0 + }, + "processing_lvl": { + "type": "integer", + "description": "Processing level", + "example": -1 + } + }, + "type": "object" + }, + "Save": { + "properties": { + "save_dir": { + "type": "string", + "description": "Directory where files will be saved", + "example": "/data" + } + }, + "type": "object" + } + }, + "responses": { + "ParseError": { + "description": "When a mask can't be parsed" + }, + "MaskError": { + "description": "When any error occurs on mask" + } + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9b0e9f3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "simple-web-controller" +version = "1.2.0" +description = "OpenHSI Flask web controller for camera operations" +readme = "README.md" +license = { file = "LICENSE" } +authors = [{ name = "OpenHSI Team" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +requires-python = ">=3.10" +dependencies = [ + "flask>=2.0.0", + "flask-restx>=1.0.0", + "openhsi", + "holoviews", + "matplotlib", + "tqdm", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = ["pytest", "black", "flake8", "mypy"] + +[project.scripts] +simple-web-controller = "simple_web_controller.cli:main" + +[project.urls] +Homepage = "https://github.com/openhsi/simple-web-controller" +Repository = "https://github.com/openhsi/simple-web-controller" + +[tool.setuptools.packages.find] +where = ["."] +include = ["simple_web_controller*"] + +[tool.setuptools.package-data] +simple_web_controller = ["templates/*", "static/**/*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..78f93b6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +# Core dependencies +flask>=2.0.0 +flask-restx>=1.0.0 +openhsi +holoviews +matplotlib +tqdm +pyyaml>=6.0 + +# Development dependencies (optional) +# pytest +# black +# flake8 +# mypy \ No newline at end of file diff --git a/server.py b/server.py deleted file mode 100644 index 8919f25..0000000 --- a/server.py +++ /dev/null @@ -1,214 +0,0 @@ -from flask import Flask, request, jsonify, render_template, send_file, send_from_directory, abort -import threading -import os -from io import BytesIO -import tempfile -import holoviews as hv -import matplotlib -matplotlib.use('Agg') - -# from openhsi.cameras import FlirCamera as openhsiCamera -from openhsi.capture import SimulatedCamera as openhsiCamera - -app = Flask(__name__) - -# Define the list of settings to show. -SETTING_KEYS = ["n_lines", "exposure_ms", "processing_lvl"] - -# Allowed processing levels with updated descriptions. -PROCESSING_LVL_OPTIONS = { - -1: "-1 - do not apply any transforms (default)", - 0: "0 - crop to useable sensor area", - 1: "1 - crop + fast smile", - 2: "2 - crop + fast smile + fast binning", - 3: "3 - crop + fast smile + slow binning", - 4: "4 - crop + fast smile + fast binning + conversion to radiance in units of uW/cm^2/sr/nm" -} - -# Initialize the camera at startup with explicit parameters. -cam = openhsiCamera( - img_path='assets/great_hall_slide.png', - n_lines=1024, - exposure_ms=1, - processing_lvl=-1, - json_path="assets/cam_settings.json", - cal_path="assets/cam_calibration.nc" -) - -# Global flags and lock for capture status. -collection_running = False -capture_finished = False -collection_lock = threading.Lock() - -def run_collection(): - global collection_running, capture_finished - with collection_lock: - collection_running = True - capture_finished = False # Clear finished flag at start. - try: - # This call blocks until capture completes. - cam.collect() - finally: - with collection_lock: - collection_running = False - capture_finished = True # Set finished flag when done. - -@app.route('/') -def index(): - # Generate form fields HTML from the settings. - form_fields = "" - for key in ["n_lines", "exposure_ms", "processing_lvl"]: - if key == "processing_lvl": - current_value = cam.settings.get(key, "") - form_fields += f'
' - form_fields += f'
' - else: - value = cam.settings.get(key, "") - form_fields += ( - f'
' - f'' - f'
' - ) - # Render the index.html template and pass the form_fields. - return render_template("index.html", form_fields=form_fields) - -@app.route('/update_settings', methods=['POST']) -def update_settings(): - new_settings = request.get_json() - try: - # Convert n_lines to an integer. - if "n_lines" in new_settings: - new_settings["n_lines"] = int(new_settings["n_lines"]) - # Convert exposure_ms to a float. - if "exposure_ms" in new_settings: - new_exposure = float(new_settings["exposure_ms"]) - except ValueError as e: - return jsonify({"error": str(e)}), 400 - - try: - # Update exposure using the set_exposure method. - if "exposure_ms" in new_settings: - cam.set_exposure(new_exposure) - # Update n_lines via reinitialisation. - if "n_lines" in new_settings: - cam.reinitialise(n_lines=new_settings["n_lines"]) - # Update processing level. - if "processing_lvl" in new_settings: - new_pl = int(new_settings["processing_lvl"]) - cam.reinitialise(processing_lvl=new_pl) - # Reset capture flag after settings change. - with collection_lock: - global capture_finished - capture_finished = False - return jsonify({"status": "success"}) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/capture', methods=['POST']) -def capture(): - global collection_running - with collection_lock: - if collection_running: - return jsonify({"status": "Capture already in progress"}), 200 - thread = threading.Thread(target=run_collection) - thread.start() - return jsonify({"status": "Capture started"}) - -@app.route('/save', methods=['POST']) -def save_files(): - data = request.get_json() - save_dir = data.get("save_dir", "data") - try: - cam.save(save_dir=save_dir) - return jsonify({"status": "success", "message": f"Files saved to {save_dir}"}) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/status', methods=['GET']) -def status(): - with collection_lock: - return jsonify({"capturing": collection_running, "finished": capture_finished}) - -@app.route('/show', methods=['GET']) -def show_image(): - # If a capture has not been completed, return a 204 No Content response. - with collection_lock: - if not capture_finished: - return '', 204 - # Otherwise, generate the image as usual. - try: - # Generate the figure using cam.show. - fig = cam.show(plot_lib="matplotlib", hist_eq=False, robust=True) - except Exception as e: - # If there's an error generating the figure, also return 204. - return '', 204 - - with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmpfile: - temp_filename = tmpfile.name - try: - hv.save(fig, temp_filename, fmt='png') - with open(temp_filename, 'rb') as f: - img_data = f.read() - buf = BytesIO(img_data) - buf.seek(0) - return send_file(buf, mimetype='image/png') - finally: - os.remove(temp_filename) - -# New endpoints for browsing directories recursively. -@app.route('/browse/', defaults={'subpath': ''}) -@app.route('/browse/') -def browse(subpath): - base_dir = "data" - current_dir = os.path.join(base_dir, subpath) - # Ensure the current_dir is within base_dir to prevent directory traversal - if not os.path.abspath(current_dir).startswith(os.path.abspath(base_dir)): - abort(403) - if not os.path.isdir(current_dir): - abort(404) - try: - items = os.listdir(current_dir) - except Exception as e: - items = [] - dirs = [] - files = [] - for item in items: - full_path = os.path.join(current_dir, item) - if os.path.isdir(full_path): - dirs.append(item) - else: - files.append(item) - # Build HTML list with navigation links. - html = "Browse Files" - html += f"

Browsing: /{subpath}

" if subpath else "

Browsing: /data

" - if subpath: - parent = os.path.dirname(subpath) - html += f'

[Parent Directory]

' - html += "" - html += '

Return to main page

' - html += "" - return html - -# Updated download endpoint to support subdirectories. -@app.route('/download/') -def download(filename): - data_dir = "data" - # This will send the file as an attachment. - return send_from_directory(data_dir, filename, as_attachment=True) - -if __name__ == '__main__': - app.run(debug=False, threaded=True) \ No newline at end of file diff --git a/simple_web_controller/__init__.py b/simple_web_controller/__init__.py new file mode 100644 index 0000000..1be0477 --- /dev/null +++ b/simple_web_controller/__init__.py @@ -0,0 +1,12 @@ +""" +Simple Web Controller for OpenHSI cameras. + +A Flask-based web interface for controlling and managing OpenHSI hyperspectral cameras. +""" + +__version__ = "1.1.0" +__author__ = "OpenHSI Team" + +from .server import app + +__all__ = ["app"] \ No newline at end of file diff --git a/simple_web_controller/cli.py b/simple_web_controller/cli.py new file mode 100644 index 0000000..f963624 --- /dev/null +++ b/simple_web_controller/cli.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +""" +Command line interface for the Simple Web Controller. +""" + +import sys +import os +import argparse +from pathlib import Path + +from . import server +from .server import app, load_config, add_log_message +from .systemd import install_service, uninstall_service, service_status + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="OpenHSI Flask Web Controller", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run the server + simple-web-controller # Use default config.yaml + simple-web-controller -c my_config.yaml # Use custom config file + simple-web-controller --host 0.0.0.0 # Bind to all interfaces + + # Service management + simple-web-controller install-service # Install systemd service + simple-web-controller service-status # Check service status + simple-web-controller uninstall-service # Remove systemd service + """ + ) + + # Create subparsers for different commands + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Server command (default) + server_parser = subparsers.add_parser('server', help='Run the web server (default)') + server_parser.add_argument( + '--config', '-c', + default='config.yaml', + help='Path to configuration file (default: config.yaml)' + ) + server_parser.add_argument( + '--host', + default='127.0.0.1', + help='Host to bind to (default: 127.0.0.1)' + ) + server_parser.add_argument( + '--port', '-p', + type=int, + default=5000, + help='Port to bind to (default: 5000)' + ) + server_parser.add_argument( + '--debug', + action='store_true', + help='Enable debug mode (overrides config)' + ) + + # Service management commands + install_parser = subparsers.add_parser('install-service', help='Install systemd service') + install_parser.add_argument( + '--config', '-c', + default='config.yaml', + help='Path to configuration file (default: config.yaml)' + ) + install_parser.add_argument( + '--working-directory', + default=os.getcwd(), + help='Working directory for the service (default: current directory)' + ) + install_parser.add_argument( + '--user', + help='User to run the service as (default: current user)' + ) + install_parser.add_argument( + '--host', + default='127.0.0.1', + help='Host to bind to (default: 127.0.0.1)' + ) + install_parser.add_argument( + '--port', '-p', + type=int, + default=5000, + help='Port to bind to (default: 5000)' + ) + install_parser.add_argument( + '--service-name', + default='simple-web-controller', + help='Name of the systemd service (default: simple-web-controller)' + ) + install_parser.add_argument( + '--enable', + action='store_true', + default=True, + help='Enable the service to start on boot (default: true)' + ) + install_parser.add_argument( + '--no-enable', + action='store_true', + help='Do not enable the service to start on boot' + ) + install_parser.add_argument( + '--start', + action='store_true', + help='Start the service after installation' + ) + + uninstall_parser = subparsers.add_parser('uninstall-service', help='Uninstall systemd service') + uninstall_parser.add_argument( + '--service-name', + default='simple-web-controller', + help='Name of the systemd service (default: simple-web-controller)' + ) + + status_parser = subparsers.add_parser('service-status', help='Check systemd service status') + status_parser.add_argument( + '--service-name', + default='simple-web-controller', + help='Name of the systemd service (default: simple-web-controller)' + ) + + # Add the original arguments as top-level for backward compatibility + parser.add_argument( + '--config', '-c', + default='config.yaml', + help='Path to configuration file (default: config.yaml)' + ) + parser.add_argument( + '--host', + default='127.0.0.1', + help='Host to bind to (default: 127.0.0.1)' + ) + parser.add_argument( + '--port', '-p', + type=int, + default=5000, + help='Port to bind to (default: 5000)' + ) + parser.add_argument( + '--debug', + action='store_true', + help='Enable debug mode (overrides config)' + ) + + return parser.parse_args() + + +def run_server(args): + """Run the web server.""" + # Check if config file exists + config_path = Path(args.config) + if not config_path.exists(): + print(f"Error: Configuration file '{args.config}' not found") + print(f"Please create a config file or specify a different path with --config") + sys.exit(1) + + try: + # Update the server module to use the specified config + server.config = load_config(args.config) + + # Re-extract configuration values in the server module + server.json_path = server.config['camera']['json_path'] + server.cal_path = server.config['camera']['cal_path'] + server.default_camera_settings = server.config['camera']['default_settings'] + server.data_directory = server.config['files']['data_directory'] + server.default_save_directory = server.config['files']['default_save_directory'] + server.max_log_messages = server.config['logging']['max_log_messages'] + server.server_debug = server.config['server']['debug'] + server.server_threaded = server.config['server']['threaded'] + + # Reinitialize camera with new config + server.cam = server.openhsiCamera( + n_lines=server.default_camera_settings['n_lines'], + exposure_ms=server.default_camera_settings['exposure_ms'], + processing_lvl=server.default_camera_settings['processing_lvl'], + json_path=server.json_path, + cal_path=server.cal_path, + ) + + # Get server settings from config, allow CLI args to override + debug = args.debug or server.server_debug + threaded = server.server_threaded + + print(f"Starting Simple Web Controller...") + print(f"Config file: {args.config}") + print(f"Server: http://{args.host}:{args.port}") + print(f"API docs: http://{args.host}:{args.port}/api/apidocs") + + # Add initial log message + add_log_message("Server started", "success") + + # Start the Flask app + app.run( + host=args.host, + port=args.port, + debug=debug, + threaded=threaded + ) + + except Exception as e: + print(f"Error starting server: {e}") + sys.exit(1) + + +def handle_install_service(args): + """Handle service installation.""" + # Check if config file exists + config_path = Path(args.config) + if not config_path.exists(): + print(f"Error: Configuration file '{args.config}' not found") + print(f"Please create a config file or specify a different path with --config") + sys.exit(1) + + # Handle enable/no-enable logic + enable = args.enable and not args.no_enable + + success = install_service( + working_directory=args.working_directory, + config_file=args.config, + user=args.user, + host=args.host, + port=args.port, + service_name=args.service_name, + enable=enable, + start=args.start + ) + + if not success: + sys.exit(1) + + +def handle_uninstall_service(args): + """Handle service uninstallation.""" + success = uninstall_service(service_name=args.service_name) + if not success: + sys.exit(1) + + +def handle_service_status(args): + """Handle service status check.""" + service_status(service_name=args.service_name) + + +def main(): + """Main entry point for the CLI.""" + args = parse_args() + + # Handle different commands + if args.command == 'install-service': + handle_install_service(args) + elif args.command == 'uninstall-service': + handle_uninstall_service(args) + elif args.command == 'service-status': + handle_service_status(args) + elif args.command == 'server' or args.command is None: + # Default to server command for backward compatibility + run_server(args) + else: + print(f"Unknown command: {args.command}") + sys.exit(1) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/simple_web_controller/server.py b/simple_web_controller/server.py new file mode 100644 index 0000000..cea70b4 --- /dev/null +++ b/simple_web_controller/server.py @@ -0,0 +1,993 @@ +from flask import ( + Flask, + request, + jsonify, + render_template, + send_file, + send_from_directory, + abort, + Blueprint, +) +from flask_restx import Api, Resource, fields +import threading +import os +import time +from io import BytesIO +import tempfile +import holoviews as hv +from tqdm import tqdm +import matplotlib +import yaml +import argparse + +matplotlib.use("Agg") + +from openhsi.cameras import FlirCamera as openhsiCameraOrig + + +def load_config(config_path="config.yaml"): + """Load configuration from YAML file.""" + try: + with open(config_path, 'r') as file: + return yaml.safe_load(file) + except FileNotFoundError: + raise FileNotFoundError(f"Configuration file {config_path} not found") + except yaml.YAMLError as e: + raise ValueError(f"Error parsing YAML file: {e}") + + +# Configuration variables (will be set by CLI) +config = None +json_path = None +cal_path = None +default_camera_settings = None +data_directory = None +default_save_directory = None +max_log_messages = 100 +server_debug = False +server_threaded = True + + +# reimplemnted openhsi capture to allow capture progress feedback. +class openhsiCamera(openhsiCameraOrig): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def collect(self, progress_callback=None): + self.start_cam() + pbar = tqdm(range(self.n_lines)) + for _ in pbar: + self.put(self.get_img()) + if callable(getattr(self, "get_temp", None)): + self.cam_temperatures.put(self.get_temp()) + # If a progress_callback is provided, extract the progress data from pbar. + if progress_callback: + # pbar.format_dict returns a dictionary with useful keys + # such as 'n', 'total', 'elapsed', and 'eta'. + progress_callback(pbar.format_dict) + self.stop_cam() + + +# Camera will be initialized by CLI after config is loaded +cam = None + +app = Flask(__name__) + +# Create a blueprint for the API with a URL prefix (e.g. '/api') +api_bp = Blueprint("api", __name__, url_prefix="/api") +api = Api( + api_bp, + version="1.1", + title="OpenHSI Capture API", + description="API for managing OpenHSI capture and file operations", + doc="/apidocs", + swagger_ui_parameters={"docExpansion": "full"}, +) # Swagger UI will be at /api/apidocs + +# Register the blueprint with the main Flask app. +app.register_blueprint(api_bp) + +# Define models for the API requests. +settings_model = api.model( + "Settings", + { + "n_lines": fields.Integer( + required=False, description="Number of lines", example=512 + ), + "exposure_ms": fields.Float( + required=False, description="Exposure time in milliseconds", example=10.0 + ), + "processing_lvl": fields.Integer( + required=False, description="Processing level", example=-1 + ), + }, +) + +# Model for advanced camera settings +advanced_settings_model = api.model( + "AdvancedSettings", + { + "row_slice": fields.List( + fields.Integer, + required=False, + description="Range of rows to read from detector [start, end]", + example=[8, 913], + ), + "resolution": fields.List( + fields.Integer, + required=False, + description="Image resolution [height, width]", + example=[924, 1240], + ), + "fwhm_nm": fields.Float( + required=False, + description="Full Width at Half Maximum (spectral resolution) in nanometers", + example=4.0, + ), + "luminance": fields.Float( + required=False, description="Luminance value for calibration", example=10000 + ), + "binxy": fields.List( + fields.Integer, + required=False, + description="Binning factors [x, y]", + example=[1, 1], + ), + "win_offset": fields.List( + fields.Integer, + required=False, + description="Window offset [x, y]", + example=[96, 200], + ), + "win_resolution": fields.List( + fields.Integer, + required=False, + description="Window resolution [width, height]", + example=[924, 1240], + ), + "pixel_format": fields.String( + required=False, + description="Pixel format (Mono8, Mono12, or Mono16)", + example="Mono8", + ), + }, +) + +# Update the settings model to include advanced settings +full_settings_model = api.inherit( + "FullSettings", settings_model, advanced_settings_model +) + +save_model = api.model( + "Save", + { + "save_dir": fields.String( + required=False, + description="Directory where files will be saved", + example="/data", + ) + }, +) + +# Define the list of settings to show. +SETTING_KEYS = ["n_lines", "exposure_ms", "processing_lvl"] + +# Allowed processing levels with updated descriptions. +PROCESSING_LVL_OPTIONS = { + -1: "-1 - do not apply any transforms (default)", + 0: "0 - crop to useable sensor area", + 1: "1 - crop + fast smile", + 2: "2 - crop + fast smile + fast binning", + 3: "3 - crop + fast smile + slow binning", + 4: "4 - crop + fast smile + fast binning + conversion to radiance in units of uW/cm^2/sr/nm", +} + +# Define detailed settings with types, descriptions, and validation +DETAILED_SETTINGS = { + "row_slice": { + "type": "array_int", + "description": "Range of rows to read from detector [start, end]", + "min_value": 0, + "max_value": 1024, + "size": 2, + }, + "resolution": { + "type": "array_int", + "description": "Image resolution [height, width]", + "min_value": 1, + "max_value": 2048, + "size": 2, + }, + "fwhm_nm": { + "type": "float", + "description": "Full Width at Half Maximum (spectral resolution) in nanometers", + "min_value": 0.1, + "max_value": 100, + }, + "exposure_ms": { + "type": "float", + "description": "Exposure time in milliseconds", + "min_value": 0.1, + "max_value": 1000, + }, + "luminance": { + "type": "float", + "description": "Luminance value for calibration", + "min_value": 0, + "max_value": 100000, + }, + "binxy": { + "type": "array_int", + "description": "Binning factors [x, y]", + "min_value": 1, + "max_value": 8, + "size": 2, + }, + "win_offset": { + "type": "array_int", + "description": "Window offset [x, y]", + "min_value": 0, + "max_value": 2048, + "size": 2, + }, + "win_resolution": { + "type": "array_int", + "description": "Window resolution [width, height]", + "min_value": 1, + "max_value": 2048, + "size": 2, + }, + "pixel_format": { + "type": "select", + "description": "Pixel format", + "options": ["Mono8", "Mono12", "Mono16"], + }, +} + +# Global flags and lock for capture status. +collection_running = False +capture_finished = False +collection_lock = threading.Lock() + +# Log messages storage +log_messages = [] +log_lock = threading.Lock() + + +def add_log_message(message, message_type="info"): + """Add a message to the log with timestamp and type.""" + with log_lock: + timestamp = int(time.time() * 1000) # milliseconds since epoch + log_messages.append( + { + "timestamp": timestamp, + "time": time.strftime("%H:%M:%S"), + "message": message, + "type": message_type, + } + ) + # Keep only the last N messages from config + if len(log_messages) > max_log_messages: + log_messages.pop(0) + + +def run_collection(): + global collection_running, capture_finished, capture_progress + with collection_lock: + collection_running = True + capture_finished = False + capture_progress = {} + try: + # Pass the update_progress callback, which now receives the tqdm progress dict. + add_log_message("Collection process started", "info") + cam.collect(progress_callback=update_progress) + add_log_message("Collection completed successfully", "success") + except Exception as e: + add_log_message(f"Error during collection: {str(e)}", "error") + app.logger.error(f"Collection error: {e}") + finally: + with collection_lock: + collection_running = False + capture_finished = True + + +# Global variable to store progress info. +capture_progress = {} + + +def update_progress(progress_info): + global capture_progress + # Extract desired values from progress_info. + current = progress_info.get("n", 0) + total = progress_info.get("total", 0) + elapsed = progress_info.get("elapsed", 0) + rate = progress_info.get("rate", 0) + percentage = (current / total) * 100 if total else 0 + capture_progress = { + "current": current, + "total": total, + "elapsed": elapsed, + "rate": rate, + "percentage": percentage, + } + + +# ------------------------------------------------------------------------- +# Non-API route: Render the main index page with a settings form. +@app.route("/") +def index(): + # Generate form fields HTML from the settings. + form_fields = "" + for key in SETTING_KEYS: + if key == "processing_lvl": + current_value = cam.settings.get(key, "") + form_fields += f'
' + form_fields += ( + f'
" + else: + value = cam.settings.get(key, "") + form_fields += ( + f'
' + f'' + f"
" + ) + + # Get the current camera settings for the detailed tab + current_settings = {} + for setting_key in DETAILED_SETTINGS.keys(): + if setting_key in cam.settings: + current_settings[setting_key] = cam.settings[setting_key] + else: + # Provide default empty values based on type + setting_info = DETAILED_SETTINGS[setting_key] + if setting_info["type"] == "array_int": + current_settings[setting_key] = [0] * setting_info.get("size", 2) + elif setting_info["type"] == "float": + current_settings[setting_key] = 0.0 + elif setting_info["type"] == "select": + current_settings[setting_key] = setting_info.get("options", [""])[0] + + return render_template( + "index.html", + form_fields=form_fields, + detailed_settings=DETAILED_SETTINGS, + current_settings=current_settings, + ) + + +# ------------------------------------------------------------------------- +@api.route("/update_settings") +class UpdateSettings(Resource): + @api.expect(full_settings_model, validate=True) + @api.response(200, "Settings updated successfully") + @api.response(400, "Invalid input") + @api.response(500, "Internal error while updating settings") + @api.doc( + params={ + "n_lines": "Number of scan lines to capture", + "exposure_ms": "Exposure time in milliseconds", + "processing_lvl": "Processing level (-1 to 4)", + "row_slice": "Range of rows to read from detector [start, end]", + "resolution": "Image resolution [height, width]", + "fwhm_nm": "Full Width at Half Maximum (spectral resolution) in nanometers", + "luminance": "Luminance value for calibration", + "binxy": "Binning factors [x, y]", + "win_offset": "Window offset [x, y]", + "win_resolution": "Window resolution [width, height]", + "pixel_format": "Pixel format (Mono8, Mono12, or Mono16)", + } + ) + def post(self): + """ + Update camera settings (both basic and advanced). + + This endpoint handles both the basic settings (n_lines, exposure_ms, processing_lvl) + and the advanced detailed settings for the camera. + + Advanced settings include: + - row_slice: Range of rows to read from detector [start, end] + - resolution: Image resolution [height, width] + - fwhm_nm: Full Width at Half Maximum (spectral resolution) in nanometers + - exposure_ms: Exposure time in milliseconds + - luminance: Luminance value for calibration + - binxy: Binning factors [x, y] + - win_offset: Window offset [x, y] + - win_resolution: Window resolution [width, height] + - pixel_format: Pixel format (Mono8, Mono12, or Mono16) + """ + new_settings = request.get_json() + app.logger.info("Received update_settings payload: %s", new_settings) + + # Track which detailed settings were provided + detailed_settings_provided = {} + for key in DETAILED_SETTINGS.keys(): + if key in new_settings: + detailed_settings_provided[key] = new_settings[key] + + try: + # Validate and parse basic settings + if "n_lines" in new_settings and new_settings["n_lines"] != "": + new_settings["n_lines"] = int(new_settings["n_lines"]) + else: + # Optional: Set a default or skip if not provided. + new_settings["n_lines"] = None + + # For updating from the basic settings tab + if "exposure_ms" in new_settings and new_settings["exposure_ms"] != "": + new_exposure = float(new_settings["exposure_ms"]) + else: + # If no exposure is provided (e.g., when updating only detailed settings) + # and we already have one in the camera, use the current exposure + if hasattr(cam, "settings") and "exposure_ms" in cam.settings: + new_exposure = cam.settings["exposure_ms"] + else: + raise ValueError( + "Exposure time (exposure_ms) is required and must be a number." + ) + + if ( + "processing_lvl" in new_settings + and new_settings["processing_lvl"] != "" + ): + new_pl = int(new_settings["processing_lvl"]) + else: + # Default processing level if not provided. + new_pl = -1 + except Exception as e: + app.logger.error("Error parsing input: %s", e, exc_info=True) + return {"status": "error", "error": f"Input error: {e}"}, 400 + + try: + # Update basic camera settings + cam.set_exposure(new_exposure) + if new_settings["n_lines"] is not None: + cam.reinitialise(n_lines=new_settings["n_lines"]) + cam.reinitialise(processing_lvl=new_pl) + + # Update detailed settings if provided + if detailed_settings_provided: + app.logger.info( + "Updating detailed settings: %s", detailed_settings_provided + ) + # We should actually use the cam's API or configuration to update these settings + # This implementation would depend on the specifics of the OpenHSI camera API + # For now, we'll just log them and pretend we updated them + for key, value in detailed_settings_provided.items(): + app.logger.info(f"Would update {key} to {value}") + # In a real implementation, you would call the appropriate camera API methods + # Example: cam.set_setting(key, value) + + with collection_lock: + global capture_finished + capture_finished = False + + # Add to log + if detailed_settings_provided: + add_log_message(f"Camera advanced settings updated", "success") + else: + add_log_message( + f"Camera basic settings updated - exposure: {new_exposure}ms, lines: {new_settings['n_lines'] if new_settings['n_lines'] is not None else 'unchanged'}, processing: {new_pl}", + "success", + ) + + return {"status": "success"}, 200 + except Exception as e: + app.logger.error("Error updating settings: %s", e, exc_info=True) + add_log_message(f"Error updating camera settings: {str(e)}", "error") + return {"status": "error", "error": f"Internal error: {e}"}, 500 + + +@api.route("/capture") +class Capture(Resource): + @api.response(200, "Capture started or already in progress") + def post(self): + """Start the image capture process.""" + global collection_running + with collection_lock: + if collection_running: + add_log_message("Capture already in progress", "info") + return {"status": "Capture already in progress"}, 200 + thread = threading.Thread(target=run_collection) + thread.start() + add_log_message("Image capture started", "info") + return {"status": "Capture started"}, 200 + + +@api.route("/save") +class SaveFiles(Resource): + @api.expect(save_model, validate=True) + @api.response(200, "Files saved successfully") + @api.response(500, "Error occurred while saving files") + def post(self): + """Save the captured files to a specified directory.""" + data = request.get_json() + save_dir = data.get("save_dir", default_save_directory) + try: + cam.save(save_dir=save_dir) + filepath = ( + f"{cam.directory}/{cam.timestamps[0].strftime('%Y_%m_%d-%H_%M_%S')}.nc" + ) + add_log_message(f"Files saved to {save_dir}", "success") + return { + "status": "success", + "message": f"Files saved to {save_dir}", + "filepath": filepath, + }, 200 + except Exception as e: + add_log_message(f"Error saving files: {str(e)}", "error") + api.abort(500, str(e)) + + +@api.route("/status") +class Status(Resource): + @api.response(200, "Status retrieved successfully") + def get(self): + """Retrieve the current capture status along with progress details.""" + with collection_lock: + return { + "capturing": collection_running, + "finished": capture_finished, + "progress": capture_progress, + }, 200 + + +@api.route("/show") +class ShowImage(Resource): + @api.response(200, "Image retrieved successfully") + @api.response(204, "No Content – capture not finished or image generation error") + @api.param("hist_eq", "Apply histogram equalization", type="boolean") + @api.param("robust", "Apply robust contrast stretching", type="boolean") + @api.param("band", "Band to display (rgb, red, green, blue, nir)", type="string") + @api.param("stretch", "Contrast stretch percentage", type="integer") + def get(self): + """Retrieve the captured image as a PNG file with display options.""" + with collection_lock: + if not capture_finished: + return "", 204 + + # Parse display parameters + hist_eq = request.args.get("hist_eq", "false").lower() == "true" + robust = request.args.get("robust", "true").lower() == "true" + band = request.args.get("band", "rgb") + stretch = int(request.args.get("stretch", "0")) + + app.logger.info( + f"Showing image with settings - hist_eq: {hist_eq}, robust: {robust}, band: {band}, stretch: {stretch}" + ) + + try: + # Note: This is a simplified implementation - the actual implementation + # would depend on what parameters the cam.show() method actually supports + + # Basic parameters that cam.show() already supports + fig = cam.show(plot_lib="matplotlib", hist_eq=hist_eq, robust=robust) + + # Note: Additional parameters like band selection and stretch percentage + # would need to be implemented in the camera's show method + # For now, we'll just pass the parameters we know work + except Exception as e: + app.logger.error(f"Error generating image: {e}") + return "", 204 + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmpfile: + temp_filename = tmpfile.name + try: + hv.save(fig, temp_filename, fmt="png") + with open(temp_filename, "rb") as f: + img_data = f.read() + buf = BytesIO(img_data) + buf.seek(0) + return send_file(buf, mimetype="image/png") + finally: + os.remove(temp_filename) + + +# New endpoints for browsing directories recursively. +@app.route("/browse/", defaults={"subpath": ""}) +@app.route("/browse/") +def browse(subpath): + """ + Browse directories recursively in the data folder. + --- + tags: + - Files + parameters: + - name: subpath + in: path + required: false + description: The subdirectory path relative to the base data directory. + schema: + type: string + example: "folder/subfolder" + responses: + 200: + description: HTML page listing directories and files. + content: + text/html: + schema: + type: string + 403: + description: Forbidden - Attempt to access an unauthorized directory. + 404: + description: Not Found - The specified directory does not exist. + """ + base_dir = data_directory + current_dir = os.path.join(base_dir, subpath) + # Ensure the current_dir is within base_dir to prevent directory traversal + if not os.path.abspath(current_dir).startswith(os.path.abspath(base_dir)): + abort(403) + if not os.path.isdir(current_dir): + abort(404) + try: + items = os.listdir(current_dir) + except Exception as e: + items = [] + dirs = [] + files = [] + for item in items: + full_path = os.path.join(current_dir, item) + if os.path.isdir(full_path): + dirs.append(item) + else: + files.append(item) + + # Build HTML page with improved styling and file management + html = """ + + + Browse Files + + + + + +
+

Browsing: {path_display}

+ +
+
+ +
+
+ +
+
+
+
+ Files and Directories + Return to main page +
+
+ + + + + + + + + + """ + + # Add parent directory link if not at root + if subpath: + parent = os.path.dirname(subpath) + html += f""" + + + + + + """ + + # Add directories + for d in sorted(dirs): + new_subpath = os.path.join(subpath, d) + html += f""" + + + + + + """ + + # Add files with actions + for f in sorted(files): + new_path = os.path.join(subpath, f) + file_ext = os.path.splitext(f)[1].lower() + + actions = f'
' + + # Different action based on file type + if file_ext in [".png", ".jpg", ".jpeg", ".gif"]: + # Image files - view in browser + actions += f'View' + actions += f'Download' + else: + # Other files - direct download + actions += f'Download' + + # Add delete button for all files + actions += f'' + actions += "
" + + html += f""" + + + + + + """ + + # Close the table and add JavaScript for delete functionality + html += """ + +
TypeNameActions
..
DIR{d}
FILE{f}{actions}
+
+
+
+
+
+ + + + + + + + """ + + path_display = f"/{subpath}" if subpath else data_directory + html = html.replace("{path_display}", path_display) + + return html + + +@api.route("/view/") +class ViewFile(Resource): + @api.param("filename", "The file path relative to the data directory") + @api.response(200, "File sent for viewing") + @api.response(404, "File not found") + def get(self, filename): + """View a file (especially images) in the browser without downloading.""" + data_dir = data_directory + # Check if the path is safe (within data directory) + full_path = os.path.join(data_dir, filename) + if not os.path.abspath(full_path).startswith(os.path.abspath(data_dir)): + abort(403) # Forbidden if trying to access outside data directory + + # Check file existence + if not os.path.isfile(full_path): + abort(404) # Not found + + # For image files, return without attachment headers + return send_from_directory(data_dir, filename, as_attachment=False) + + +@api.route("/download/") +class Download(Resource): + @api.param("filename", "The file path relative to the data directory") + @api.response(200, "File sent as attachment") + @api.response(404, "File not found") + def get(self, filename): + """Download a file from the data directory.""" + data_dir = data_directory + # Check if the path is safe (within data directory) + full_path = os.path.join(data_dir, filename) + if not os.path.abspath(full_path).startswith(os.path.abspath(data_dir)): + abort(403) # Forbidden if trying to access outside data directory + + return send_from_directory(data_dir, filename, as_attachment=True) + + +@api.route("/delete/") +class DeleteFile(Resource): + @api.param("filename", "The file path relative to the data directory") + @api.response(200, "File deleted successfully") + @api.response(403, "Forbidden - Cannot delete outside data directory") + @api.response(404, "File not found") + @api.response(500, "Error occurred while deleting file") + def delete(self, filename): + """Delete a file from the data directory.""" + data_dir = data_directory + # Check if the path is safe (within /data directory) + full_path = os.path.join(data_dir, filename) + if not os.path.abspath(full_path).startswith(os.path.abspath(data_dir)): + return { + "status": "error", + "message": "Cannot delete files outside data directory", + }, 403 + + # Check file existence + if not os.path.isfile(full_path): + return {"status": "error", "message": "File not found"}, 404 + + try: + # Delete the file + os.remove(full_path) + app.logger.info(f"Deleted file: {full_path}") + return { + "status": "success", + "message": f"File {filename} deleted successfully", + }, 200 + except Exception as e: + app.logger.error(f"Error deleting file {full_path}: {e}") + return {"status": "error", "message": f"Error deleting file: {str(e)}"}, 500 + + +@api.route("/file_list") +class FileList(Resource): + @api.param( + "folder", "The folder path to list (relative to data directory)", required=False + ) + @api.response(200, "File list retrieved successfully") + @api.response(403, "Forbidden - Cannot access directory outside data directory") + @api.response(404, "Directory not found") + def get(self): + """Get a list of all files in the specified directory.""" + data_dir = data_directory + folder = request.args.get("folder", "") + + # Build the target directory path + target_dir = os.path.join(data_dir, folder) + + # Security check - ensure path is within data directory + if not os.path.abspath(target_dir).startswith(os.path.abspath(data_dir)): + return { + "status": "error", + "message": "Cannot access directory outside data directory", + }, 403 + + # Check if directory exists + if not os.path.isdir(target_dir): + return {"status": "error", "message": "Directory not found"}, 404 + + try: + # Get all items in the directory + items = os.listdir(target_dir) + + # Separate files and directories + files = [] + directories = [] + + for item in items: + item_path = os.path.join(target_dir, item) + if os.path.isdir(item_path): + # For directories, add with trailing slash + rel_path = os.path.relpath(item_path, data_dir) + directories.append( + {"name": item, "path": rel_path, "type": "directory"} + ) + else: + # For files, include size and modification time + file_stats = os.stat(item_path) + rel_path = os.path.relpath(item_path, data_dir) + + # Get file extension + _, ext = os.path.splitext(item) + + files.append( + { + "name": item, + "path": rel_path, + "type": "file", + "size": file_stats.st_size, + "modified": file_stats.st_mtime, + "extension": ext.lower(), + } + ) + + # Return both files and directories + return { + "status": "success", + "current_dir": folder or "/", + "files": files, + "directories": directories, + }, 200 + + except Exception as e: + return {"status": "error", "message": f"Error listing files: {str(e)}"}, 500 + + +@api.route("/logs") +class LogMessages(Resource): + @api.response(200, "Log messages retrieved successfully") + def get(self): + """Retrieve the log messages.""" + with log_lock: + return {"status": "success", "logs": log_messages}, 200 + + @api.response(200, "Log messages cleared successfully") + def delete(self): + """Clear the log messages.""" + with log_lock: + global log_messages + log_messages = [] + return {"status": "success", "message": "Log messages cleared"}, 200 + + +# This module can be imported as part of the package +# The CLI entry point handles running the server diff --git a/static/css/bootstrap-grid.css b/simple_web_controller/static/css/bootstrap-grid.css similarity index 100% rename from static/css/bootstrap-grid.css rename to simple_web_controller/static/css/bootstrap-grid.css diff --git a/static/css/bootstrap-grid.css.map b/simple_web_controller/static/css/bootstrap-grid.css.map similarity index 100% rename from static/css/bootstrap-grid.css.map rename to simple_web_controller/static/css/bootstrap-grid.css.map diff --git a/static/css/bootstrap-grid.min.css b/simple_web_controller/static/css/bootstrap-grid.min.css similarity index 100% rename from static/css/bootstrap-grid.min.css rename to simple_web_controller/static/css/bootstrap-grid.min.css diff --git a/static/css/bootstrap-grid.min.css.map b/simple_web_controller/static/css/bootstrap-grid.min.css.map similarity index 100% rename from static/css/bootstrap-grid.min.css.map rename to simple_web_controller/static/css/bootstrap-grid.min.css.map diff --git a/static/css/bootstrap-grid.rtl.css b/simple_web_controller/static/css/bootstrap-grid.rtl.css similarity index 100% rename from static/css/bootstrap-grid.rtl.css rename to simple_web_controller/static/css/bootstrap-grid.rtl.css diff --git a/static/css/bootstrap-grid.rtl.css.map b/simple_web_controller/static/css/bootstrap-grid.rtl.css.map similarity index 100% rename from static/css/bootstrap-grid.rtl.css.map rename to simple_web_controller/static/css/bootstrap-grid.rtl.css.map diff --git a/static/css/bootstrap-grid.rtl.min.css b/simple_web_controller/static/css/bootstrap-grid.rtl.min.css similarity index 100% rename from static/css/bootstrap-grid.rtl.min.css rename to simple_web_controller/static/css/bootstrap-grid.rtl.min.css diff --git a/static/css/bootstrap-grid.rtl.min.css.map b/simple_web_controller/static/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from static/css/bootstrap-grid.rtl.min.css.map rename to simple_web_controller/static/css/bootstrap-grid.rtl.min.css.map diff --git a/static/css/bootstrap-reboot.css b/simple_web_controller/static/css/bootstrap-reboot.css similarity index 100% rename from static/css/bootstrap-reboot.css rename to simple_web_controller/static/css/bootstrap-reboot.css diff --git a/static/css/bootstrap-reboot.css.map b/simple_web_controller/static/css/bootstrap-reboot.css.map similarity index 100% rename from static/css/bootstrap-reboot.css.map rename to simple_web_controller/static/css/bootstrap-reboot.css.map diff --git a/static/css/bootstrap-reboot.min.css b/simple_web_controller/static/css/bootstrap-reboot.min.css similarity index 100% rename from static/css/bootstrap-reboot.min.css rename to simple_web_controller/static/css/bootstrap-reboot.min.css diff --git a/static/css/bootstrap-reboot.min.css.map b/simple_web_controller/static/css/bootstrap-reboot.min.css.map similarity index 100% rename from static/css/bootstrap-reboot.min.css.map rename to simple_web_controller/static/css/bootstrap-reboot.min.css.map diff --git a/static/css/bootstrap-reboot.rtl.css b/simple_web_controller/static/css/bootstrap-reboot.rtl.css similarity index 100% rename from static/css/bootstrap-reboot.rtl.css rename to simple_web_controller/static/css/bootstrap-reboot.rtl.css diff --git a/static/css/bootstrap-reboot.rtl.css.map b/simple_web_controller/static/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from static/css/bootstrap-reboot.rtl.css.map rename to simple_web_controller/static/css/bootstrap-reboot.rtl.css.map diff --git a/static/css/bootstrap-reboot.rtl.min.css b/simple_web_controller/static/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from static/css/bootstrap-reboot.rtl.min.css rename to simple_web_controller/static/css/bootstrap-reboot.rtl.min.css diff --git a/static/css/bootstrap-reboot.rtl.min.css.map b/simple_web_controller/static/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from static/css/bootstrap-reboot.rtl.min.css.map rename to simple_web_controller/static/css/bootstrap-reboot.rtl.min.css.map diff --git a/static/css/bootstrap-utilities.css b/simple_web_controller/static/css/bootstrap-utilities.css similarity index 100% rename from static/css/bootstrap-utilities.css rename to simple_web_controller/static/css/bootstrap-utilities.css diff --git a/static/css/bootstrap-utilities.css.map b/simple_web_controller/static/css/bootstrap-utilities.css.map similarity index 100% rename from static/css/bootstrap-utilities.css.map rename to simple_web_controller/static/css/bootstrap-utilities.css.map diff --git a/static/css/bootstrap-utilities.min.css b/simple_web_controller/static/css/bootstrap-utilities.min.css similarity index 100% rename from static/css/bootstrap-utilities.min.css rename to simple_web_controller/static/css/bootstrap-utilities.min.css diff --git a/static/css/bootstrap-utilities.min.css.map b/simple_web_controller/static/css/bootstrap-utilities.min.css.map similarity index 100% rename from static/css/bootstrap-utilities.min.css.map rename to simple_web_controller/static/css/bootstrap-utilities.min.css.map diff --git a/static/css/bootstrap-utilities.rtl.css b/simple_web_controller/static/css/bootstrap-utilities.rtl.css similarity index 100% rename from static/css/bootstrap-utilities.rtl.css rename to simple_web_controller/static/css/bootstrap-utilities.rtl.css diff --git a/static/css/bootstrap-utilities.rtl.css.map b/simple_web_controller/static/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from static/css/bootstrap-utilities.rtl.css.map rename to simple_web_controller/static/css/bootstrap-utilities.rtl.css.map diff --git a/static/css/bootstrap-utilities.rtl.min.css b/simple_web_controller/static/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from static/css/bootstrap-utilities.rtl.min.css rename to simple_web_controller/static/css/bootstrap-utilities.rtl.min.css diff --git a/static/css/bootstrap-utilities.rtl.min.css.map b/simple_web_controller/static/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from static/css/bootstrap-utilities.rtl.min.css.map rename to simple_web_controller/static/css/bootstrap-utilities.rtl.min.css.map diff --git a/static/css/bootstrap.css b/simple_web_controller/static/css/bootstrap.css similarity index 100% rename from static/css/bootstrap.css rename to simple_web_controller/static/css/bootstrap.css diff --git a/static/css/bootstrap.css.map b/simple_web_controller/static/css/bootstrap.css.map similarity index 100% rename from static/css/bootstrap.css.map rename to simple_web_controller/static/css/bootstrap.css.map diff --git a/static/css/bootstrap.min.css b/simple_web_controller/static/css/bootstrap.min.css similarity index 100% rename from static/css/bootstrap.min.css rename to simple_web_controller/static/css/bootstrap.min.css diff --git a/static/css/bootstrap.min.css.map b/simple_web_controller/static/css/bootstrap.min.css.map similarity index 100% rename from static/css/bootstrap.min.css.map rename to simple_web_controller/static/css/bootstrap.min.css.map diff --git a/static/css/bootstrap.rtl.css b/simple_web_controller/static/css/bootstrap.rtl.css similarity index 100% rename from static/css/bootstrap.rtl.css rename to simple_web_controller/static/css/bootstrap.rtl.css diff --git a/static/css/bootstrap.rtl.css.map b/simple_web_controller/static/css/bootstrap.rtl.css.map similarity index 100% rename from static/css/bootstrap.rtl.css.map rename to simple_web_controller/static/css/bootstrap.rtl.css.map diff --git a/static/css/bootstrap.rtl.min.css b/simple_web_controller/static/css/bootstrap.rtl.min.css similarity index 100% rename from static/css/bootstrap.rtl.min.css rename to simple_web_controller/static/css/bootstrap.rtl.min.css diff --git a/static/css/bootstrap.rtl.min.css.map b/simple_web_controller/static/css/bootstrap.rtl.min.css.map similarity index 100% rename from static/css/bootstrap.rtl.min.css.map rename to simple_web_controller/static/css/bootstrap.rtl.min.css.map diff --git a/static/js/bootstrap.bundle.js b/simple_web_controller/static/js/bootstrap.bundle.js similarity index 100% rename from static/js/bootstrap.bundle.js rename to simple_web_controller/static/js/bootstrap.bundle.js diff --git a/static/js/bootstrap.bundle.js.map b/simple_web_controller/static/js/bootstrap.bundle.js.map similarity index 100% rename from static/js/bootstrap.bundle.js.map rename to simple_web_controller/static/js/bootstrap.bundle.js.map diff --git a/static/js/bootstrap.bundle.min.js b/simple_web_controller/static/js/bootstrap.bundle.min.js similarity index 100% rename from static/js/bootstrap.bundle.min.js rename to simple_web_controller/static/js/bootstrap.bundle.min.js diff --git a/static/js/bootstrap.bundle.min.js.map b/simple_web_controller/static/js/bootstrap.bundle.min.js.map similarity index 100% rename from static/js/bootstrap.bundle.min.js.map rename to simple_web_controller/static/js/bootstrap.bundle.min.js.map diff --git a/static/js/bootstrap.esm.js b/simple_web_controller/static/js/bootstrap.esm.js similarity index 100% rename from static/js/bootstrap.esm.js rename to simple_web_controller/static/js/bootstrap.esm.js diff --git a/static/js/bootstrap.esm.js.map b/simple_web_controller/static/js/bootstrap.esm.js.map similarity index 100% rename from static/js/bootstrap.esm.js.map rename to simple_web_controller/static/js/bootstrap.esm.js.map diff --git a/static/js/bootstrap.esm.min.js b/simple_web_controller/static/js/bootstrap.esm.min.js similarity index 100% rename from static/js/bootstrap.esm.min.js rename to simple_web_controller/static/js/bootstrap.esm.min.js diff --git a/static/js/bootstrap.esm.min.js.map b/simple_web_controller/static/js/bootstrap.esm.min.js.map similarity index 100% rename from static/js/bootstrap.esm.min.js.map rename to simple_web_controller/static/js/bootstrap.esm.min.js.map diff --git a/static/js/bootstrap.js b/simple_web_controller/static/js/bootstrap.js similarity index 100% rename from static/js/bootstrap.js rename to simple_web_controller/static/js/bootstrap.js diff --git a/static/js/bootstrap.js.map b/simple_web_controller/static/js/bootstrap.js.map similarity index 100% rename from static/js/bootstrap.js.map rename to simple_web_controller/static/js/bootstrap.js.map diff --git a/static/js/bootstrap.min.js b/simple_web_controller/static/js/bootstrap.min.js similarity index 100% rename from static/js/bootstrap.min.js rename to simple_web_controller/static/js/bootstrap.min.js diff --git a/static/js/bootstrap.min.js.map b/simple_web_controller/static/js/bootstrap.min.js.map similarity index 100% rename from static/js/bootstrap.min.js.map rename to simple_web_controller/static/js/bootstrap.min.js.map diff --git a/simple_web_controller/systemd.py b/simple_web_controller/systemd.py new file mode 100644 index 0000000..8ef074c --- /dev/null +++ b/simple_web_controller/systemd.py @@ -0,0 +1,250 @@ +""" +Systemd service management for Simple Web Controller. +""" + +import os +import sys +import subprocess +import shutil +from pathlib import Path +from typing import Optional + + +def get_current_user(): + """Get the current user name.""" + return os.getenv('USER') or os.getenv('USERNAME') + + +def get_python_executable(): + """Get the path to the current Python executable.""" + return sys.executable + + +def get_simple_web_controller_executable(): + """Get the path to the simple-web-controller executable.""" + # Try to find the executable in the current environment + executable_path = shutil.which('simple-web-controller') + if executable_path: + return executable_path + + # If not found, construct the path based on the Python executable + python_dir = Path(sys.executable).parent + simple_web_controller_path = python_dir / 'simple-web-controller' + + if simple_web_controller_path.exists(): + return str(simple_web_controller_path) + + # Fall back to using python -m + return f"{sys.executable} -m simple_web_controller.cli" + + +def create_service_file( + working_directory: str, + config_file: str = "config.yaml", + user: Optional[str] = None, + host: str = "127.0.0.1", + port: int = 5000, + service_name: str = "simple-web-controller" +) -> str: + """ + Create a systemd service file content. + + Args: + working_directory: Directory where the service should run + config_file: Path to the config file (relative to working directory) + user: User to run the service as (defaults to current user) + host: Host to bind to + port: Port to bind to + service_name: Name of the service + + Returns: + Service file content as string + """ + if user is None: + user = get_current_user() + + executable = get_simple_web_controller_executable() + + # Build the command + if config_file == "config.yaml": + # Default config file + exec_start = f"{executable} --host {host} --port {port}" + else: + # Custom config file + exec_start = f"{executable} --config {config_file} --host {host} --port {port}" + + service_content = f"""[Unit] +Description=Simple Web Controller - OpenHSI Flask Web Server +After=network.target + +[Service] +Type=simple +User={user} +WorkingDirectory={working_directory} +ExecStart={exec_start} +Restart=always +RestartSec=3 +Environment=FLASK_ENV=production + +[Install] +WantedBy=multi-user.target +""" + + return service_content + + +def install_service( + working_directory: str, + config_file: str = "config.yaml", + user: Optional[str] = None, + host: str = "127.0.0.1", + port: int = 5000, + service_name: str = "simple-web-controller", + enable: bool = True, + start: bool = False +) -> bool: + """ + Install and optionally enable/start the systemd service. + + Args: + working_directory: Directory where the service should run + config_file: Path to the config file + user: User to run the service as + host: Host to bind to + port: Port to bind to + service_name: Name of the service + enable: Whether to enable the service + start: Whether to start the service after installation + + Returns: + True if successful, False otherwise + """ + try: + # Create service file content + service_content = create_service_file( + working_directory=working_directory, + config_file=config_file, + user=user, + host=host, + port=port, + service_name=service_name + ) + + # Service file path + service_file_path = f"/etc/systemd/system/{service_name}.service" + + print(f"Creating systemd service file: {service_file_path}") + print("Service content:") + print("-" * 50) + print(service_content) + print("-" * 50) + + # Write service file (requires sudo) + write_cmd = ['sudo', 'tee', service_file_path] + result = subprocess.run( + write_cmd, + input=service_content, + text=True, + capture_output=True + ) + + if result.returncode != 0: + print(f"Error writing service file: {result.stderr}") + return False + + # Reload systemd + print("Reloading systemd daemon...") + reload_result = subprocess.run(['sudo', 'systemctl', 'daemon-reload'], capture_output=True) + if reload_result.returncode != 0: + print(f"Error reloading systemd: {reload_result.stderr.decode()}") + return False + + # Enable service if requested + if enable: + print(f"Enabling service {service_name}...") + enable_result = subprocess.run(['sudo', 'systemctl', 'enable', service_name], capture_output=True) + if enable_result.returncode != 0: + print(f"Error enabling service: {enable_result.stderr.decode()}") + return False + + # Start service if requested + if start: + print(f"Starting service {service_name}...") + start_result = subprocess.run(['sudo', 'systemctl', 'start', service_name], capture_output=True) + if start_result.returncode != 0: + print(f"Error starting service: {start_result.stderr.decode()}") + return False + + print(f"✓ Service {service_name} installed successfully!") + + if enable and not start: + print(f"To start the service, run: sudo systemctl start {service_name}") + + print(f"To check service status: sudo systemctl status {service_name}") + print(f"To view logs: sudo journalctl -u {service_name} -f") + + return True + + except Exception as e: + print(f"Error installing service: {e}") + return False + + +def uninstall_service(service_name: str = "simple-web-controller") -> bool: + """ + Uninstall the systemd service. + + Args: + service_name: Name of the service to uninstall + + Returns: + True if successful, False otherwise + """ + try: + service_file_path = f"/etc/systemd/system/{service_name}.service" + + # Stop the service if running + print(f"Stopping service {service_name}...") + stop_result = subprocess.run(['sudo', 'systemctl', 'stop', service_name], capture_output=True) + + # Disable the service + print(f"Disabling service {service_name}...") + disable_result = subprocess.run(['sudo', 'systemctl', 'disable', service_name], capture_output=True) + + # Remove service file + if os.path.exists(service_file_path): + print(f"Removing service file: {service_file_path}") + remove_result = subprocess.run(['sudo', 'rm', service_file_path], capture_output=True) + if remove_result.returncode != 0: + print(f"Error removing service file: {remove_result.stderr.decode()}") + return False + + # Reload systemd + print("Reloading systemd daemon...") + reload_result = subprocess.run(['sudo', 'systemctl', 'daemon-reload'], capture_output=True) + if reload_result.returncode != 0: + print(f"Error reloading systemd: {reload_result.stderr.decode()}") + return False + + print(f"✓ Service {service_name} uninstalled successfully!") + return True + + except Exception as e: + print(f"Error uninstalling service: {e}") + return False + + +def service_status(service_name: str = "simple-web-controller") -> None: + """ + Show the status of the systemd service. + + Args: + service_name: Name of the service + """ + try: + result = subprocess.run(['systemctl', 'status', service_name], capture_output=True, text=True) + print(result.stdout) + if result.stderr: + print(result.stderr) + except Exception as e: + print(f"Error checking service status: {e}") \ No newline at end of file diff --git a/simple_web_controller/templates/index.html b/simple_web_controller/templates/index.html new file mode 100644 index 0000000..c8b0a6b --- /dev/null +++ b/simple_web_controller/templates/index.html @@ -0,0 +1,616 @@ + + + + + Camera Control Interface + + + + + + + +
+

Camera Control Interface

+
Idle
+ + + + +
+ +
+
+
+

Capture Controls

+ + +
+ + +
+ + Browse Files +
+ +
+

Image Preview

+ +
+
+ Display Settings +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+
+
+
+ + + +
+ Captured image +
+
+
+
+ + +
+

Basic Camera Settings

+
+ {{ form_fields | safe }} + +
+
+ + +
+

Advanced Camera Settings

+

These settings require deeper knowledge of the camera and may require camera + restart to take effect.

+
+ {% for key, info in detailed_settings.items() %} +
+ +
+ {% if info.type == 'array_int' %} + {% for i in range(info.size) %} + + {% if not loop.last %},{% endif %} + {% endfor %} + {% elif info.type == 'float' %} + + {% elif info.type == 'select' %} + + {% endif %} +
+ {{ info.description }} +
+ {% endfor %} + +
+
+ + +
+
+
+
+

Message Log

+ +
+
+
+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 129864d..0000000 --- a/templates/index.html +++ /dev/null @@ -1,183 +0,0 @@ - - - - - Camera Control Interface - - - - - - -
-

Camera Control Interface

-
Idle
- - -
-

Camera Settings

-
- {{ form_fields | safe }} - - -
-
- -
-
-
-
- - -
- - Browse Files -
-
-
- -
-

Show Image

-
- -
-
- Captured image -
-
-
- - - - - \ No newline at end of file