main
This commit is contained in:
commit
9c81a49f8f
48
bootstrap.ps1
Normal file
48
bootstrap.ps1
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<#
|
||||||
|
Bootstrap script for DslrDirector on Windows.
|
||||||
|
- Creates a local virtual environment (.venv) if missing
|
||||||
|
- Activates the venv, upgrades pip, installs requirements.txt
|
||||||
|
- Runs the Flask app (src/app.py)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
Open PowerShell in the project folder and run:
|
||||||
|
.\bootstrap.ps1
|
||||||
|
Or explicitly allow script execution if needed:
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\bootstrap.ps1
|
||||||
|
|
||||||
|
Optional: pass a specific python executable:
|
||||||
|
.\bootstrap.ps1 -PythonPath "C:\Path\To\python.exe"
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$PythonPath = "python"
|
||||||
|
)
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location $root
|
||||||
|
|
||||||
|
$venv = Join-Path $root '.venv'
|
||||||
|
if (-not (Test-Path $venv)) {
|
||||||
|
Write-Host "Creating virtual environment at $venv"
|
||||||
|
& $PythonPath -m venv $venv
|
||||||
|
} else {
|
||||||
|
Write-Host "Virtual environment exists at $venv"
|
||||||
|
}
|
||||||
|
|
||||||
|
$activate = Join-Path $venv 'Scripts\Activate.ps1'
|
||||||
|
if (-not (Test-Path $activate)) {
|
||||||
|
Write-Error "Activation script not found: $activate"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Activating venv..."
|
||||||
|
# Dot-source the activation script to the current session
|
||||||
|
. $activate
|
||||||
|
|
||||||
|
Write-Host "Upgrading pip and installing requirements..."
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
if (Test-Path (Join-Path $root 'requirements.txt')) {
|
||||||
|
python -m pip install -r (Join-Path $root 'requirements.txt')
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting application... (press Ctrl-C to stop)"
|
||||||
|
python .\src\app.py
|
||||||
BIN
data/ata_logo_out.png
Normal file
BIN
data/ata_logo_out.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
14
data/comm.json
Normal file
14
data/comm.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"comm": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8080
|
||||||
|
},
|
||||||
|
"ble": {
|
||||||
|
"auto-connect": true,
|
||||||
|
"address": "E8:06:90:D5:CA:65",
|
||||||
|
"device-name": "SP110E-ATA-64",
|
||||||
|
"filter-name": "SP110E-ATA",
|
||||||
|
"service-uuid": "0xFFE0",
|
||||||
|
"characteristic-uuid": "0xFFE1"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
data/config.json
Normal file
10
data/config.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"animation-events": {
|
||||||
|
"home-state": "animation",
|
||||||
|
"home-anim": 65,
|
||||||
|
"sharing-state": "animation",
|
||||||
|
"sharing-anim": 18,
|
||||||
|
"home-color": "#8000ff",
|
||||||
|
"sharing-color": "#008000"
|
||||||
|
}
|
||||||
|
}
|
||||||
5090
data/gui_start.log
Normal file
5090
data/gui_start.log
Normal file
File diff suppressed because it is too large
Load Diff
4666
dslrbooth_triggers.jsonl
Normal file
4666
dslrbooth_triggers.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
11176
dslrbooth_triggers.log
Normal file
11176
dslrbooth_triggers.log
Normal file
File diff suppressed because it is too large
Load Diff
12
environment.yml
Normal file
12
environment.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
name: dslrdirector
|
||||||
|
channels:
|
||||||
|
- conda-forge
|
||||||
|
dependencies:
|
||||||
|
- python=3.12
|
||||||
|
- pip
|
||||||
|
- pip:
|
||||||
|
- -r requirements.txt
|
||||||
|
# Usage:
|
||||||
|
# conda env create -f environment.yml
|
||||||
|
# conda activate dslrdirector
|
||||||
|
# python src/app.py
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
bleak
|
||||||
28
scripts/README_BACKUP.md
Normal file
28
scripts/README_BACKUP.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
Backup to Gitea
|
||||||
|
|
||||||
|
This folder contains a PowerShell script to snapshot the repository and push the snapshot into a Gitea backup repository.
|
||||||
|
|
||||||
|
Quick steps
|
||||||
|
|
||||||
|
1. On your Gitea server create a repository for backups (for example username/dslr-director-backups). Make it private if desired.
|
||||||
|
2. Create a personal access token in Gitea for your user with repository push permissions.
|
||||||
|
3. From the repository root run (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$GITEA_URL = 'https://gitea.example.com'
|
||||||
|
$GITEA_USER = 'youruser'
|
||||||
|
$GITEA_TOKEN = 'YOUR_TOKEN'
|
||||||
|
.\scripts\backup_to_gitea.ps1 -GiteaUrl $GITEA_URL -GiteaUser $GITEA_USER -GiteaToken $GITEA_TOKEN -BackupRepo "youruser/dslr-director-backups"
|
||||||
|
```
|
||||||
|
|
||||||
|
What the script does
|
||||||
|
|
||||||
|
- Clones the backup repo into a temporary directory (or pulls latest if already present).
|
||||||
|
- Creates a timestamped folder under `snapshots/` and copies the project files there (skips `.git` and virtual environments).
|
||||||
|
- Commits and pushes the snapshot to the backup repo using the provided credentials.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
|
||||||
|
- The script uses `robocopy` for directory copy on Windows. Make sure Git and robocopy are available in PATH.
|
||||||
|
- The script uses HTTPS pushes with credentials in the URL. Keep your token secret.
|
||||||
|
- You can schedule this script as a Windows Scheduled Task to run nightly.
|
||||||
101
scripts/backup_to_gitea.ps1
Normal file
101
scripts/backup_to_gitea.ps1
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<#
|
||||||
|
Backup the repository to a Gitea backup repository.
|
||||||
|
|
||||||
|
Usage (PowerShell):
|
||||||
|
# one-time: create a Gitea backup repo and get a personal access token
|
||||||
|
$GITEA_URL = 'https://gitea.example.com'
|
||||||
|
$GITEA_USER = 'username'
|
||||||
|
$GITEA_TOKEN = 'PERSONAL_ACCESS_TOKEN'
|
||||||
|
.\scripts\backup_to_gitea.ps1 -GiteaUrl $GITEA_URL -GiteaUser $GITEA_USER -GiteaToken $GITEA_TOKEN -BackupRepo "username/dslr-director-backups"
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
- Creates a timestamped snapshot directory under a local temporary clone of the backup repo
|
||||||
|
- Copies the current project files into that snapshot (excluding .venv and .git)
|
||||||
|
- Commits and pushes the snapshot to the remote Gitea backup repository
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The backup repo must already exist on your Gitea server (or create it via the web UI/API).
|
||||||
|
- Your Gitea token should have repo:push permissions. For HTTPS push the token is used as password.
|
||||||
|
- This script is idempotent for the same timestamp but you will normally run it manually or from a scheduled task.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)] [string] $GiteaUrl,
|
||||||
|
[Parameter(Mandatory=$true)] [string] $GiteaUser,
|
||||||
|
[Parameter(Mandatory=$true)] [string] $GiteaToken,
|
||||||
|
[Parameter(Mandatory=$true)] [string] $BackupRepo, # e.g. "username/dslr-director-backups"
|
||||||
|
[string] $TempDir = "$env:TEMP\dslr_backups",
|
||||||
|
[string] $RepoRoot = "$(Resolve-Path ..)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure Git is available
|
||||||
|
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Error "git not found in PATH. Install git first."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normalize paths
|
||||||
|
$RepoRoot = Resolve-Path -Path $RepoRoot | Select-Object -ExpandProperty Path
|
||||||
|
$TempDir = Resolve-Path -Path $TempDir -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Path -ErrorAction SilentlyContinue
|
||||||
|
if (-not $TempDir) { New-Item -ItemType Directory -Path "$env:TEMP\dslr_backups" -Force | Out-Null; $TempDir = "$env:TEMP\dslr_backups" }
|
||||||
|
|
||||||
|
# Make clone path
|
||||||
|
$repoName = $BackupRepo.Split('/')[-1]
|
||||||
|
$clonePath = Join-Path $TempDir $repoName
|
||||||
|
|
||||||
|
# Build remote URL with token for HTTPS push
|
||||||
|
# Format: https://<user>:<token>@gitea.example.com/<owner>/<repo>.git
|
||||||
|
$remoteUrl = $GiteaUrl.TrimEnd('/') + "/" + $BackupRepo + ".git"
|
||||||
|
$remoteUrlAuth = $remoteUrl -replace '^(https?://)', "`$1$($GiteaUser):$($GiteaToken)@"
|
||||||
|
|
||||||
|
Write-Host "Using repo root: $RepoRoot"
|
||||||
|
Write-Host "Cloning backup repo into: $clonePath"
|
||||||
|
|
||||||
|
if (-not (Test-Path $clonePath)) {
|
||||||
|
git clone $remoteUrl $clonePath 2>&1 | Write-Host
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Failed to clone backup repo. Check credentials and repo existence."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Push-Location $clonePath
|
||||||
|
git pull 2>&1 | Write-Host
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create timestamped snapshot folder
|
||||||
|
$ts = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||||
|
$snapshotDir = Join-Path $clonePath "snapshots\$ts"
|
||||||
|
New-Item -ItemType Directory -Path $snapshotDir -Force | Out-Null
|
||||||
|
|
||||||
|
# Rsync-like copy: use robocopy to preserve structure and skip .git and venv
|
||||||
|
$exclude = @('.git', '.venv', 'venv', 'node_modules')
|
||||||
|
$copyOptions = @('/E','/COPY:DAT','/R:2','/W:1')
|
||||||
|
|
||||||
|
foreach ($entry in Get-ChildItem -Path $RepoRoot -Force) {
|
||||||
|
if ($exclude -contains $entry.Name) { continue }
|
||||||
|
$dest = Join-Path $snapshotDir $entry.Name
|
||||||
|
if ($entry.PSIsContainer) {
|
||||||
|
robocopy $entry.FullName $dest @copyOptions | Out-Null
|
||||||
|
} else {
|
||||||
|
Copy-Item -Path $entry.FullName -Destination $dest -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Commit and push
|
||||||
|
Push-Location $clonePath
|
||||||
|
try {
|
||||||
|
git add . 2>&1 | Write-Host
|
||||||
|
git commit -m "Backup snapshot: $ts" 2>&1 | Write-Host
|
||||||
|
# push using auth in URL
|
||||||
|
git push $remoteUrlAuth HEAD:main 2>&1 | Write-Host
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Push failed. You may need to set the remote or use a different branch."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "Backup pushed to $BackupRepo as snapshot $ts"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Backup commit/push failed: $_"
|
||||||
|
exit 1
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
557
src/BleComm.py
Normal file
557
src/BleComm.py
Normal file
@ -0,0 +1,557 @@
|
|||||||
|
"""
|
||||||
|
BLE communication helper for DslrDirector.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- On import, nothing starts automatically. Call start() to begin the background connect/reconnect loop.
|
||||||
|
- Reads configuration from ../data/config.json (relative to src/)
|
||||||
|
- Tries to connect to BLE device using address if provided; otherwise scans for device by name.
|
||||||
|
- Keeps attempting to connect if device not present (reconnect loop).
|
||||||
|
- Exposes send_led_command(data0, data1, data2, cmd) to send a 4-byte packet to configured characteristic.
|
||||||
|
|
||||||
|
This module uses `bleak` (async BLE library). It runs an asyncio event loop in a dedicated background thread so the rest
|
||||||
|
of the Flask app can remain synchronous.
|
||||||
|
|
||||||
|
The packet format expected by the device is 4 bytes: data0, data1, data2, cmd.
|
||||||
|
Example: { data0: 0x00, data1: 0x00, data2: 0x00, cmd: 0x00 }
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, Any
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# module logger
|
||||||
|
logger = logging.getLogger("BleComm")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
if not logger.handlers:
|
||||||
|
ch = logging.StreamHandler()
|
||||||
|
ch.setFormatter(logging.Formatter("%(asctime)s.%(msecs)03d %(levelname)s:%(name)s:%(message)s", datefmt="%H:%M:%S"))
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
||||||
|
# if the Flask app logger is available, prefer that for consistency
|
||||||
|
try:
|
||||||
|
from src import app as _app_module
|
||||||
|
app_logger = getattr(_app_module, 'app', None)
|
||||||
|
if app_logger is not None:
|
||||||
|
app_logger = getattr(app_logger, 'logger', None) or app_logger
|
||||||
|
else:
|
||||||
|
app_logger = None
|
||||||
|
except Exception:
|
||||||
|
app_logger = None
|
||||||
|
|
||||||
|
def _log_info(msg, *args):
|
||||||
|
try:
|
||||||
|
if app_logger:
|
||||||
|
app_logger.info(msg % args if args else msg)
|
||||||
|
else:
|
||||||
|
logger.info(msg, *args)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
logger.info(msg, *args)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _log_error(msg, *args):
|
||||||
|
try:
|
||||||
|
if app_logger:
|
||||||
|
app_logger.error(msg % args if args else msg)
|
||||||
|
else:
|
||||||
|
logger.error(msg, *args)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
logger.error(msg, *args)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# third-party bleak library for cross-platform BLE
|
||||||
|
try:
|
||||||
|
from bleak import BleakClient, BleakScanner
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
# We don't fail import-time; start() will raise a helpful error if bleak missing
|
||||||
|
BleakClient = None # type: ignore
|
||||||
|
BleakScanner = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
# default paths
|
||||||
|
HERE = os.path.dirname(__file__)
|
||||||
|
CONFIG_PATH = os.path.abspath(os.path.join(HERE, "..", "data", "config.json"))
|
||||||
|
# Secondary/modern comm path (preferred for BLE/communication settings)
|
||||||
|
COMM_PATH = os.path.abspath(os.path.join(HERE, "..", "data", "comm.json"))
|
||||||
|
|
||||||
|
# runtime state
|
||||||
|
_loop_thread: Optional[threading.Thread] = None
|
||||||
|
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
_stop_event = threading.Event()
|
||||||
|
_client: Optional[Any] = None
|
||||||
|
_client_lock = threading.Lock()
|
||||||
|
_config: dict = {}
|
||||||
|
_last_connected_ts: Optional[float] = None
|
||||||
|
|
||||||
|
# connection info filled from config
|
||||||
|
_address: str = ""
|
||||||
|
_device_name: str = ""
|
||||||
|
_service_uuid: Optional[str] = None
|
||||||
|
_characteristic_uuid: Optional[str] = None
|
||||||
|
|
||||||
|
# reconnect timing
|
||||||
|
_RECONNECT_DELAY = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config():
|
||||||
|
global _config, _address, _device_name, _service_uuid, _characteristic_uuid
|
||||||
|
try:
|
||||||
|
# Prefer the modern comm.json if present (keeps BLE/comm settings separate)
|
||||||
|
cfg_path = COMM_PATH if os.path.exists(COMM_PATH) else CONFIG_PATH
|
||||||
|
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||||
|
_config = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
_config = {}
|
||||||
|
|
||||||
|
ble = _config.get("ble", {}) if isinstance(_config, dict) else {}
|
||||||
|
_address = (ble.get("address") or "").strip()
|
||||||
|
_device_name = (ble.get("device-name") or "").strip()
|
||||||
|
_service_uuid = ble.get("service-uuid") or None
|
||||||
|
_filter_name = ble.get("filter-name") or None
|
||||||
|
_characteristic_uuid = ble.get("characteristic-uuid") or None
|
||||||
|
_log_info("Loaded BLE config: address='%s' name='%s' filter='%s' service='%s' char='%s'", _address, _device_name, _filter_name, _service_uuid, _characteristic_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
async def _scan_for_name(name: str, timeout: float = 5.0) -> Optional[str]:
|
||||||
|
if BleakScanner is None:
|
||||||
|
raise RuntimeError("bleak library not available")
|
||||||
|
_log_info("Scanning for BLE device name '%s' (timeout=%.1fs)", name, timeout)
|
||||||
|
try:
|
||||||
|
devices = await BleakScanner.discover(timeout=timeout)
|
||||||
|
except Exception as e:
|
||||||
|
_log_error("BLE scan failed: %s", e)
|
||||||
|
return None
|
||||||
|
for d in devices:
|
||||||
|
# d.name may be None on some platforms
|
||||||
|
if d.name and d.name == name:
|
||||||
|
_log_info("Found device by name: %s -> %s", d.name, d.address)
|
||||||
|
return d.address
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _connect_loop():
|
||||||
|
global _client, _last_connected_ts
|
||||||
|
if BleakClient is None:
|
||||||
|
raise RuntimeError("bleak library not installed. Install via `pip install bleak`")
|
||||||
|
|
||||||
|
while not _stop_event.is_set():
|
||||||
|
try:
|
||||||
|
# ensure config is loaded
|
||||||
|
_load_config()
|
||||||
|
target_addr = _address
|
||||||
|
if not target_addr and _device_name:
|
||||||
|
try:
|
||||||
|
target_addr = await _scan_for_name(_device_name, timeout=5.0)
|
||||||
|
except Exception:
|
||||||
|
target_addr = None
|
||||||
|
|
||||||
|
if not target_addr:
|
||||||
|
# nothing found, wait and retry
|
||||||
|
_log_info("No BLE target found; retrying in %.1fs", _RECONNECT_DELAY)
|
||||||
|
await asyncio.sleep(_RECONNECT_DELAY)
|
||||||
|
continue
|
||||||
|
|
||||||
|
client = BleakClient(target_addr)
|
||||||
|
try:
|
||||||
|
_log_info("Attempting to connect to BLE address %s", target_addr)
|
||||||
|
await client.connect(timeout=10.0)
|
||||||
|
_log_info("Connected to BLE device %s", target_addr)
|
||||||
|
# store client under lock and record last-connected timestamp
|
||||||
|
with _client_lock:
|
||||||
|
_client = client
|
||||||
|
_last_connected_ts = time.time()
|
||||||
|
# stay connected until disconnected or stop requested
|
||||||
|
while client.is_connected and not _stop_event.is_set():
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
except Exception:
|
||||||
|
_log_error("Failed to connect or disconnect cleanly to %s", target_addr)
|
||||||
|
try:
|
||||||
|
await client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
with _client_lock:
|
||||||
|
_client = None
|
||||||
|
# fall through to reconnect after delay
|
||||||
|
finally:
|
||||||
|
with _client_lock:
|
||||||
|
if _client is client and not client.is_connected:
|
||||||
|
_client = None
|
||||||
|
_log_info("Client no longer connected: %s", target_addr)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# keep looping
|
||||||
|
_log_error("Exception in connect loop", )
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.sleep(_RECONNECT_DELAY)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_loop_running():
|
||||||
|
global _loop_thread, _loop
|
||||||
|
if _loop_thread and _loop_thread.is_alive():
|
||||||
|
return
|
||||||
|
|
||||||
|
if BleakClient is None:
|
||||||
|
raise RuntimeError("bleak is required. Install with: pip install bleak")
|
||||||
|
|
||||||
|
def _runner():
|
||||||
|
global _loop
|
||||||
|
_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(_loop)
|
||||||
|
try:
|
||||||
|
_loop.run_until_complete(_connect_loop())
|
||||||
|
finally:
|
||||||
|
# cleanup tasks
|
||||||
|
try:
|
||||||
|
_loop.run_until_complete(_loop.shutdown_asyncgens())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_loop.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_stop_event.clear()
|
||||||
|
_loop_thread = threading.Thread(target=_runner, name="BleCommLoop", daemon=True)
|
||||||
|
_loop_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def start():
|
||||||
|
"""Start background loop to manage BLE connection."""
|
||||||
|
|
||||||
|
_load_config()
|
||||||
|
_ensure_loop_running()
|
||||||
|
_log_info("BLE background loop started")
|
||||||
|
|
||||||
|
|
||||||
|
def stop():
|
||||||
|
"""Request background loop to stop and disconnect client."""
|
||||||
|
_stop_event.set()
|
||||||
|
# disconnect client if present
|
||||||
|
try:
|
||||||
|
with _client_lock:
|
||||||
|
c = _client
|
||||||
|
if c is not None:
|
||||||
|
# schedule disconnect on the loop
|
||||||
|
_log_info("Scheduling BLE disconnect")
|
||||||
|
fut = asyncio.run_coroutine_threadsafe(c.disconnect(), _loop)
|
||||||
|
try:
|
||||||
|
fut.result(3)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_now() -> Optional[Any]:
|
||||||
|
with _client_lock:
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def send_led_command(data0: int, data1: int, data2: int, cmd: int) -> bool:
|
||||||
|
"""Send 4-byte LED command to the configured characteristic. Returns True on success."""
|
||||||
|
# Diagnostic: log caller/process/thread and current client/loop state
|
||||||
|
try:
|
||||||
|
import os as _os
|
||||||
|
import threading as _threading
|
||||||
|
import inspect as _inspect
|
||||||
|
caller = _inspect.stack()[1]
|
||||||
|
_log_info("send_led_command called from %s:%s (pid=%s, thread=%s) args=(%s,%s,%s,%s)",
|
||||||
|
caller.filename, caller.lineno, _os.getpid(), _threading.get_ident(),
|
||||||
|
str(data0), str(data1), str(data2), str(cmd))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Accept ints, floats that are whole numbers, or numeric strings (hex or decimal).
|
||||||
|
def _to_int_byte(x):
|
||||||
|
# strings: allow hex (0x..) or decimal
|
||||||
|
if isinstance(x, str):
|
||||||
|
s = x.strip()
|
||||||
|
if s.lower().startswith('0x'):
|
||||||
|
return int(s, 16)
|
||||||
|
return int(s, 10)
|
||||||
|
if isinstance(x, float):
|
||||||
|
if not x.is_integer():
|
||||||
|
raise ValueError('data bytes must be integer values')
|
||||||
|
return int(x)
|
||||||
|
if isinstance(x, int):
|
||||||
|
return x
|
||||||
|
raise ValueError('data bytes must be integer-like')
|
||||||
|
|
||||||
|
try:
|
||||||
|
data0 = _to_int_byte(data0)
|
||||||
|
data1 = _to_int_byte(data1)
|
||||||
|
data2 = _to_int_byte(data2)
|
||||||
|
cmd = _to_int_byte(cmd)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("data bytes must be integers 0..255")
|
||||||
|
|
||||||
|
for v in (data0, data1, data2, cmd):
|
||||||
|
if not (0 <= v <= 0xFF):
|
||||||
|
raise ValueError("data bytes must be integers 0..255")
|
||||||
|
|
||||||
|
# prepare payload
|
||||||
|
payload = bytes([data0 & 0xFF, data1 & 0xFF, data2 & 0xFF, cmd & 0xFF])
|
||||||
|
|
||||||
|
client = _get_client_now()
|
||||||
|
if client is None:
|
||||||
|
_log_error("send_led_command: no BLE client available (maybe not connected yet)")
|
||||||
|
return False
|
||||||
|
if not getattr(client, 'is_connected', False):
|
||||||
|
_log_error("send_led_command: client present but not connected")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# write in the event loop thread
|
||||||
|
if _loop is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _write():
|
||||||
|
# resolve characteristic uuid (string like '0xFFE1' or full UUID)
|
||||||
|
char = _characteristic_uuid
|
||||||
|
if not char:
|
||||||
|
raise RuntimeError("characteristic_uuid not configured in data/config.json")
|
||||||
|
# Normalize common short forms like '0xFFE1' or 'FFE1' to full 128-bit UUID.
|
||||||
|
try:
|
||||||
|
if isinstance(char, int):
|
||||||
|
# integer 0xFFE1 -> hex string
|
||||||
|
short = f"{char:04X}"
|
||||||
|
resolved = f"0000{short}-0000-1000-8000-00805f9b34fb"
|
||||||
|
else:
|
||||||
|
s = str(char).strip()
|
||||||
|
if s.lower().startswith('0x'):
|
||||||
|
s = s[2:]
|
||||||
|
# remove braces if present
|
||||||
|
s_clean = s.strip('{}')
|
||||||
|
if len(s_clean) == 4:
|
||||||
|
resolved = f"0000{s_clean}-0000-1000-8000-00805f9b34fb"
|
||||||
|
elif len(s_clean) == 8 and '-' not in s_clean:
|
||||||
|
resolved = f"{s_clean}-0000-1000-8000-00805f9b34fb"
|
||||||
|
else:
|
||||||
|
resolved = s
|
||||||
|
except Exception:
|
||||||
|
resolved = char
|
||||||
|
|
||||||
|
_log_info("Writing to characteristic (resolved) %s (original: %s)", resolved, _characteristic_uuid)
|
||||||
|
await client.write_gatt_char(resolved, payload)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fut = asyncio.run_coroutine_threadsafe(_write(), _loop)
|
||||||
|
fut.result(timeout=3)
|
||||||
|
_log_info("Wrote payload to characteristic %s: %s", _characteristic_uuid, payload.hex())
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
# include exception text for diagnostics
|
||||||
|
_log_error("Failed to write payload to characteristic %s: %s", _characteristic_uuid, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# simple module-level README usage in comments
|
||||||
|
# Example usage:
|
||||||
|
# from src import BleComm
|
||||||
|
# BleComm.start()
|
||||||
|
# BleComm.send_led_command(0x00, 0x00, 0x00, 0x01)
|
||||||
|
# BleComm.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def get_status() -> dict:
|
||||||
|
"""Return a small status dict for diagnostics.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- connected: bool
|
||||||
|
- address: Optional[str]
|
||||||
|
- last_connected_ts: Optional[float]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _client_lock:
|
||||||
|
client = _client
|
||||||
|
addr = globals().get('_address')
|
||||||
|
connected = False
|
||||||
|
if client is not None:
|
||||||
|
connected = bool(getattr(client, 'is_connected', False))
|
||||||
|
return {
|
||||||
|
'connected': connected,
|
||||||
|
'address': addr,
|
||||||
|
'last_connected_ts': globals().get('_last_connected_ts')
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
_log_error(f"get_status error: {e}")
|
||||||
|
return {'connected': False, 'address': None, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# Public helpers used by the Flask app (synchronous wrappers)
|
||||||
|
def scan(prefix: str = '', timeout: float = 5.0):
|
||||||
|
"""Scan for BLE devices and return a list of dicts {name,address,id}.
|
||||||
|
|
||||||
|
This is a synchronous wrapper around BleakScanner.discover.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
if BleakScanner is None:
|
||||||
|
_log_error('scan: bleak not available')
|
||||||
|
return results
|
||||||
|
try:
|
||||||
|
devices = asyncio.run(BleakScanner.discover(timeout=timeout))
|
||||||
|
for d in devices:
|
||||||
|
name = getattr(d, 'name', None) or ''
|
||||||
|
addr = getattr(d, 'address', None) or getattr(d, 'id', None) or ''
|
||||||
|
results.append({'name': name, 'address': addr, 'id': addr})
|
||||||
|
if prefix:
|
||||||
|
pf = prefix.lower()
|
||||||
|
results = [r for r in results if (r.get('name') or '').lower().startswith(pf)]
|
||||||
|
except Exception as e:
|
||||||
|
_log_error('scan failed: %s', e)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def connect(addr_or_name: str) -> bool:
|
||||||
|
"""Synchronous connect helper: try to connect by address; if looks like a name, write it to config and let the background loop handle connecting."""
|
||||||
|
try:
|
||||||
|
# if looks like an address, try direct connect via BleakClient
|
||||||
|
if BleakClient is None:
|
||||||
|
_log_error('connect: bleak not available')
|
||||||
|
return False
|
||||||
|
# Quick heuristic for MAC-like addresses (colon or dash separated) or hex
|
||||||
|
candidate = (addr_or_name or '').strip()
|
||||||
|
if not candidate:
|
||||||
|
return False
|
||||||
|
if ':' in candidate or '-' in candidate or len(candidate) >= 12:
|
||||||
|
# attempt a direct connect using a temporary client
|
||||||
|
try:
|
||||||
|
async def _do_connect():
|
||||||
|
c = BleakClient(candidate)
|
||||||
|
await c.connect(timeout=10.0)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await c.disconnect()
|
||||||
|
return True
|
||||||
|
return asyncio.run(_do_connect())
|
||||||
|
except Exception as e:
|
||||||
|
_log_error('direct connect attempt failed: %s', e)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# assume it's a device name: write to config and let background loop find it
|
||||||
|
try:
|
||||||
|
# persist desired device name into config file
|
||||||
|
_load_config()
|
||||||
|
_config['ble'] = _config.get('ble', {})
|
||||||
|
_config['ble']['device_name'] = candidate
|
||||||
|
# prefer writing into comm.json to match the Flask app's expectations
|
||||||
|
try:
|
||||||
|
out_path = COMM_PATH
|
||||||
|
with open(out_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(_config, f, indent=2)
|
||||||
|
except Exception:
|
||||||
|
# fallback to legacy config.json if write to comm.json fails
|
||||||
|
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(_config, f, indent=2)
|
||||||
|
_log_info('connect: wrote device_name to config: %s', candidate)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_log_error('connect write config failed: %s', e)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
_log_error('connect wrapper failed: %s', e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def disconnect() -> bool:
|
||||||
|
"""Synchronous disconnect helper: schedule disconnect on running loop if client exists."""
|
||||||
|
try:
|
||||||
|
with _client_lock:
|
||||||
|
c = _client
|
||||||
|
if c is None:
|
||||||
|
_log_info('disconnect: no client present')
|
||||||
|
return True
|
||||||
|
if _loop is None:
|
||||||
|
_log_error('disconnect: no event loop available')
|
||||||
|
return False
|
||||||
|
fut = asyncio.run_coroutine_threadsafe(c.disconnect(), _loop)
|
||||||
|
try:
|
||||||
|
fut.result(3)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_log_info('disconnect: requested')
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_log_error('disconnect failed: %s', e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _interactive_menu():
|
||||||
|
"""Simple interactive CLI for manual testing when run as a script."""
|
||||||
|
print("BleComm interactive test menu")
|
||||||
|
print("Commands:")
|
||||||
|
print(" 1) Send Command (prompt for 4 bytes)")
|
||||||
|
print(" 2) Status")
|
||||||
|
print(" 3) Quit")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = input('> ').strip()
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
print('\nExiting')
|
||||||
|
break
|
||||||
|
|
||||||
|
if choice in ('1', 'send', 'send command'):
|
||||||
|
try:
|
||||||
|
print('Enter a single byte value for animation (hex like 0x1A or decimal). Leave empty to cancel.')
|
||||||
|
s = input('animation: ').strip()
|
||||||
|
if s == '':
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
if s.lower().startswith('0x'):
|
||||||
|
v = int(s, 16)
|
||||||
|
else:
|
||||||
|
v = int(s, 10)
|
||||||
|
if not (0 <= v <= 0xFF):
|
||||||
|
raise ValueError('byte out of range')
|
||||||
|
# keep the call form unchanged as requested (second arg is float literal 0.)
|
||||||
|
ok = send_led_command(v, 0., 0, 0x2C)
|
||||||
|
print('send_led_command returned', ok)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('\nSend cancelled')
|
||||||
|
except Exception as e:
|
||||||
|
print('Send failed:', e)
|
||||||
|
elif choice in ('2', 'status'):
|
||||||
|
try:
|
||||||
|
st = get_status()
|
||||||
|
print('Status:', st)
|
||||||
|
except Exception as e:
|
||||||
|
print('Status failed:', e)
|
||||||
|
elif choice in ('3', 'quit', 'q', 'exit'):
|
||||||
|
print('Quitting')
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print('Unknown command')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# Allow running this module directly for manual BLE tests
|
||||||
|
try:
|
||||||
|
print('Starting BleComm background loop...')
|
||||||
|
start()
|
||||||
|
# wait for connection (timeout after ~30s)
|
||||||
|
waited = 0.0
|
||||||
|
interval = 0.5
|
||||||
|
timeout = 30.0
|
||||||
|
print('Waiting for device to connect (timeout 30s)...')
|
||||||
|
while waited < timeout:
|
||||||
|
st = get_status()
|
||||||
|
if st.get('connected'):
|
||||||
|
print('Device connected:', st.get('address'))
|
||||||
|
break
|
||||||
|
time.sleep(interval)
|
||||||
|
waited += interval
|
||||||
|
else:
|
||||||
|
print('Timeout waiting for device to connect; you can still use the menu and try send commands (they will fail until connected).')
|
||||||
|
|
||||||
|
_interactive_menu()
|
||||||
|
except Exception as e:
|
||||||
|
print('Interactive menu error:', e)
|
||||||
|
|
||||||
BIN
src/__pycache__/BleComm.cpython-312.pyc
Normal file
BIN
src/__pycache__/BleComm.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/app.cpython-312.pyc
Normal file
BIN
src/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/gui.cpython-312.pyc
Normal file
BIN
src/__pycache__/gui.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/gui_psg.cpython-312.pyc
Normal file
BIN
src/__pycache__/gui_psg.cpython-312.pyc
Normal file
Binary file not shown.
1310
src/app.py
Normal file
1310
src/app.py
Normal file
File diff suppressed because it is too large
Load Diff
1521
src/gui.py
Normal file
1521
src/gui.py
Normal file
File diff suppressed because it is too large
Load Diff
20
src/test_blecomm.py
Normal file
20
src/test_blecomm.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""Simple test harness for BleComm module.
|
||||||
|
|
||||||
|
Run this with your project venv active. It will start the BleComm background loop,
|
||||||
|
attempt to connect, and try to send a simple LED command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from src import BleComm
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
print("Starting BleComm...")
|
||||||
|
BleComm.start()
|
||||||
|
print("Sleeping 8s to allow discovery/connection...")
|
||||||
|
time.sleep(8)
|
||||||
|
ok = BleComm.send_led_command(0x00, 0x00, 0x00, 0x01)
|
||||||
|
print("send_led_command returned:", ok)
|
||||||
|
finally:
|
||||||
|
BleComm.stop()
|
||||||
|
print("Stopped BleComm")
|
||||||
107
static/css/styles.css
Normal file
107
static/css/styles.css
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
width: 100%;
|
||||||
|
background-color: black;
|
||||||
|
border-bottom: 2px solid white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box; /* Ensure padding and border are included in the width */
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-left {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative; /* Ensure relative positioning for the submenu */
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar ul li {
|
||||||
|
float: left;
|
||||||
|
position: relative; /* Ensure relative positioning for the submenu */
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar ul li a {
|
||||||
|
display: block;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-right: 1px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar ul li a:hover {
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar ul li a.disabled {
|
||||||
|
color: grey;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar ul .submenu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
background-color: black;
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 2px solid white;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar ul .submenu li {
|
||||||
|
float: none;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar ul .submenu li a {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar ul .submenu li a:hover {
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar ul li:hover > .submenu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.nav-image {
|
||||||
|
height: 40px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box; /* Ensure padding and border are included in the width */
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 90%; /* Increased width */
|
||||||
|
max-width: 1400px; /* Increased max-width */
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box; /* Ensure padding and border are included in the width */
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
8030
static/fontawesome/css/all.css
vendored
Normal file
8030
static/fontawesome/css/all.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
9
static/fontawesome/css/all.min.css
vendored
Normal file
9
static/fontawesome/css/all.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1594
static/fontawesome/css/brands.css
vendored
Normal file
1594
static/fontawesome/css/brands.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
6
static/fontawesome/css/brands.min.css
vendored
Normal file
6
static/fontawesome/css/brands.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
6375
static/fontawesome/css/fontawesome.css
vendored
Normal file
6375
static/fontawesome/css/fontawesome.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
9
static/fontawesome/css/fontawesome.min.css
vendored
Normal file
9
static/fontawesome/css/fontawesome.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
19
static/fontawesome/css/regular.css
vendored
Normal file
19
static/fontawesome/css/regular.css
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||||
|
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
* Copyright 2024 Fonticons, Inc.
|
||||||
|
*/
|
||||||
|
:root, :host {
|
||||||
|
--fa-style-family-classic: 'Font Awesome 6 Free';
|
||||||
|
--fa-font-regular: normal 400 1em/1 'Font Awesome 6 Free'; }
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Font Awesome 6 Free';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: block;
|
||||||
|
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); }
|
||||||
|
|
||||||
|
.far,
|
||||||
|
.fa-regular {
|
||||||
|
font-weight: 400; }
|
||||||
6
static/fontawesome/css/regular.min.css
vendored
Normal file
6
static/fontawesome/css/regular.min.css
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||||
|
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
* Copyright 2024 Fonticons, Inc.
|
||||||
|
*/
|
||||||
|
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}
|
||||||
19
static/fontawesome/css/solid.css
vendored
Normal file
19
static/fontawesome/css/solid.css
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||||
|
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
* Copyright 2024 Fonticons, Inc.
|
||||||
|
*/
|
||||||
|
:root, :host {
|
||||||
|
--fa-style-family-classic: 'Font Awesome 6 Free';
|
||||||
|
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Free'; }
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Font Awesome 6 Free';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: block;
|
||||||
|
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); }
|
||||||
|
|
||||||
|
.fas,
|
||||||
|
.fa-solid {
|
||||||
|
font-weight: 900; }
|
||||||
6
static/fontawesome/css/solid.min.css
vendored
Normal file
6
static/fontawesome/css/solid.min.css
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||||
|
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
* Copyright 2024 Fonticons, Inc.
|
||||||
|
*/
|
||||||
|
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}
|
||||||
640
static/fontawesome/css/svg-with-js.css
vendored
Normal file
640
static/fontawesome/css/svg-with-js.css
vendored
Normal file
@ -0,0 +1,640 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||||
|
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
* Copyright 2024 Fonticons, Inc.
|
||||||
|
*/
|
||||||
|
:root, :host {
|
||||||
|
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Solid';
|
||||||
|
--fa-font-regular: normal 400 1em/1 'Font Awesome 6 Regular';
|
||||||
|
--fa-font-light: normal 300 1em/1 'Font Awesome 6 Light';
|
||||||
|
--fa-font-thin: normal 100 1em/1 'Font Awesome 6 Thin';
|
||||||
|
--fa-font-duotone: normal 900 1em/1 'Font Awesome 6 Duotone';
|
||||||
|
--fa-font-sharp-solid: normal 900 1em/1 'Font Awesome 6 Sharp';
|
||||||
|
--fa-font-sharp-regular: normal 400 1em/1 'Font Awesome 6 Sharp';
|
||||||
|
--fa-font-sharp-light: normal 300 1em/1 'Font Awesome 6 Sharp';
|
||||||
|
--fa-font-sharp-thin: normal 100 1em/1 'Font Awesome 6 Sharp';
|
||||||
|
--fa-font-brands: normal 400 1em/1 'Font Awesome 6 Brands'; }
|
||||||
|
|
||||||
|
svg:not(:root).svg-inline--fa, svg:not(:host).svg-inline--fa {
|
||||||
|
overflow: visible;
|
||||||
|
box-sizing: content-box; }
|
||||||
|
|
||||||
|
.svg-inline--fa {
|
||||||
|
display: var(--fa-display, inline-block);
|
||||||
|
height: 1em;
|
||||||
|
overflow: visible;
|
||||||
|
vertical-align: -.125em; }
|
||||||
|
.svg-inline--fa.fa-2xs {
|
||||||
|
vertical-align: 0.1em; }
|
||||||
|
.svg-inline--fa.fa-xs {
|
||||||
|
vertical-align: 0em; }
|
||||||
|
.svg-inline--fa.fa-sm {
|
||||||
|
vertical-align: -0.07143em; }
|
||||||
|
.svg-inline--fa.fa-lg {
|
||||||
|
vertical-align: -0.2em; }
|
||||||
|
.svg-inline--fa.fa-xl {
|
||||||
|
vertical-align: -0.25em; }
|
||||||
|
.svg-inline--fa.fa-2xl {
|
||||||
|
vertical-align: -0.3125em; }
|
||||||
|
.svg-inline--fa.fa-pull-left {
|
||||||
|
margin-right: var(--fa-pull-margin, 0.3em);
|
||||||
|
width: auto; }
|
||||||
|
.svg-inline--fa.fa-pull-right {
|
||||||
|
margin-left: var(--fa-pull-margin, 0.3em);
|
||||||
|
width: auto; }
|
||||||
|
.svg-inline--fa.fa-li {
|
||||||
|
width: var(--fa-li-width, 2em);
|
||||||
|
top: 0.25em; }
|
||||||
|
.svg-inline--fa.fa-fw {
|
||||||
|
width: var(--fa-fw-width, 1.25em); }
|
||||||
|
|
||||||
|
.fa-layers svg.svg-inline--fa {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: auto;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0; }
|
||||||
|
|
||||||
|
.fa-layers-text, .fa-layers-counter {
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
text-align: center; }
|
||||||
|
|
||||||
|
.fa-layers {
|
||||||
|
display: inline-block;
|
||||||
|
height: 1em;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: -.125em;
|
||||||
|
width: 1em; }
|
||||||
|
.fa-layers svg.svg-inline--fa {
|
||||||
|
-webkit-transform-origin: center center;
|
||||||
|
transform-origin: center center; }
|
||||||
|
|
||||||
|
.fa-layers-text {
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
-webkit-transform: translate(-50%, -50%);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
-webkit-transform-origin: center center;
|
||||||
|
transform-origin: center center; }
|
||||||
|
|
||||||
|
.fa-layers-counter {
|
||||||
|
background-color: var(--fa-counter-background-color, #ff253a);
|
||||||
|
border-radius: var(--fa-counter-border-radius, 1em);
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--fa-inverse, #fff);
|
||||||
|
line-height: var(--fa-counter-line-height, 1);
|
||||||
|
max-width: var(--fa-counter-max-width, 5em);
|
||||||
|
min-width: var(--fa-counter-min-width, 1.5em);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--fa-counter-padding, 0.25em 0.5em);
|
||||||
|
right: var(--fa-right, 0);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
top: var(--fa-top, 0);
|
||||||
|
-webkit-transform: scale(var(--fa-counter-scale, 0.25));
|
||||||
|
transform: scale(var(--fa-counter-scale, 0.25));
|
||||||
|
-webkit-transform-origin: top right;
|
||||||
|
transform-origin: top right; }
|
||||||
|
|
||||||
|
.fa-layers-bottom-right {
|
||||||
|
bottom: var(--fa-bottom, 0);
|
||||||
|
right: var(--fa-right, 0);
|
||||||
|
top: auto;
|
||||||
|
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||||
|
transform: scale(var(--fa-layers-scale, 0.25));
|
||||||
|
-webkit-transform-origin: bottom right;
|
||||||
|
transform-origin: bottom right; }
|
||||||
|
|
||||||
|
.fa-layers-bottom-left {
|
||||||
|
bottom: var(--fa-bottom, 0);
|
||||||
|
left: var(--fa-left, 0);
|
||||||
|
right: auto;
|
||||||
|
top: auto;
|
||||||
|
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||||
|
transform: scale(var(--fa-layers-scale, 0.25));
|
||||||
|
-webkit-transform-origin: bottom left;
|
||||||
|
transform-origin: bottom left; }
|
||||||
|
|
||||||
|
.fa-layers-top-right {
|
||||||
|
top: var(--fa-top, 0);
|
||||||
|
right: var(--fa-right, 0);
|
||||||
|
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||||
|
transform: scale(var(--fa-layers-scale, 0.25));
|
||||||
|
-webkit-transform-origin: top right;
|
||||||
|
transform-origin: top right; }
|
||||||
|
|
||||||
|
.fa-layers-top-left {
|
||||||
|
left: var(--fa-left, 0);
|
||||||
|
right: auto;
|
||||||
|
top: var(--fa-top, 0);
|
||||||
|
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||||
|
transform: scale(var(--fa-layers-scale, 0.25));
|
||||||
|
-webkit-transform-origin: top left;
|
||||||
|
transform-origin: top left; }
|
||||||
|
|
||||||
|
.fa-1x {
|
||||||
|
font-size: 1em; }
|
||||||
|
|
||||||
|
.fa-2x {
|
||||||
|
font-size: 2em; }
|
||||||
|
|
||||||
|
.fa-3x {
|
||||||
|
font-size: 3em; }
|
||||||
|
|
||||||
|
.fa-4x {
|
||||||
|
font-size: 4em; }
|
||||||
|
|
||||||
|
.fa-5x {
|
||||||
|
font-size: 5em; }
|
||||||
|
|
||||||
|
.fa-6x {
|
||||||
|
font-size: 6em; }
|
||||||
|
|
||||||
|
.fa-7x {
|
||||||
|
font-size: 7em; }
|
||||||
|
|
||||||
|
.fa-8x {
|
||||||
|
font-size: 8em; }
|
||||||
|
|
||||||
|
.fa-9x {
|
||||||
|
font-size: 9em; }
|
||||||
|
|
||||||
|
.fa-10x {
|
||||||
|
font-size: 10em; }
|
||||||
|
|
||||||
|
.fa-2xs {
|
||||||
|
font-size: 0.625em;
|
||||||
|
line-height: 0.1em;
|
||||||
|
vertical-align: 0.225em; }
|
||||||
|
|
||||||
|
.fa-xs {
|
||||||
|
font-size: 0.75em;
|
||||||
|
line-height: 0.08333em;
|
||||||
|
vertical-align: 0.125em; }
|
||||||
|
|
||||||
|
.fa-sm {
|
||||||
|
font-size: 0.875em;
|
||||||
|
line-height: 0.07143em;
|
||||||
|
vertical-align: 0.05357em; }
|
||||||
|
|
||||||
|
.fa-lg {
|
||||||
|
font-size: 1.25em;
|
||||||
|
line-height: 0.05em;
|
||||||
|
vertical-align: -0.075em; }
|
||||||
|
|
||||||
|
.fa-xl {
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: 0.04167em;
|
||||||
|
vertical-align: -0.125em; }
|
||||||
|
|
||||||
|
.fa-2xl {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 0.03125em;
|
||||||
|
vertical-align: -0.1875em; }
|
||||||
|
|
||||||
|
.fa-fw {
|
||||||
|
text-align: center;
|
||||||
|
width: 1.25em; }
|
||||||
|
|
||||||
|
.fa-ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin-left: var(--fa-li-margin, 2.5em);
|
||||||
|
padding-left: 0; }
|
||||||
|
.fa-ul > li {
|
||||||
|
position: relative; }
|
||||||
|
|
||||||
|
.fa-li {
|
||||||
|
left: calc(var(--fa-li-width, 2em) * -1);
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
width: var(--fa-li-width, 2em);
|
||||||
|
line-height: inherit; }
|
||||||
|
|
||||||
|
.fa-border {
|
||||||
|
border-color: var(--fa-border-color, #eee);
|
||||||
|
border-radius: var(--fa-border-radius, 0.1em);
|
||||||
|
border-style: var(--fa-border-style, solid);
|
||||||
|
border-width: var(--fa-border-width, 0.08em);
|
||||||
|
padding: var(--fa-border-padding, 0.2em 0.25em 0.15em); }
|
||||||
|
|
||||||
|
.fa-pull-left {
|
||||||
|
float: left;
|
||||||
|
margin-right: var(--fa-pull-margin, 0.3em); }
|
||||||
|
|
||||||
|
.fa-pull-right {
|
||||||
|
float: right;
|
||||||
|
margin-left: var(--fa-pull-margin, 0.3em); }
|
||||||
|
|
||||||
|
.fa-beat {
|
||||||
|
-webkit-animation-name: fa-beat;
|
||||||
|
animation-name: fa-beat;
|
||||||
|
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
-webkit-animation-timing-function: var(--fa-animation-timing, ease-in-out);
|
||||||
|
animation-timing-function: var(--fa-animation-timing, ease-in-out); }
|
||||||
|
|
||||||
|
.fa-bounce {
|
||||||
|
-webkit-animation-name: fa-bounce;
|
||||||
|
animation-name: fa-bounce;
|
||||||
|
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1));
|
||||||
|
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1)); }
|
||||||
|
|
||||||
|
.fa-fade {
|
||||||
|
-webkit-animation-name: fa-fade;
|
||||||
|
animation-name: fa-fade;
|
||||||
|
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
|
||||||
|
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); }
|
||||||
|
|
||||||
|
.fa-beat-fade {
|
||||||
|
-webkit-animation-name: fa-beat-fade;
|
||||||
|
animation-name: fa-beat-fade;
|
||||||
|
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
|
||||||
|
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); }
|
||||||
|
|
||||||
|
.fa-flip {
|
||||||
|
-webkit-animation-name: fa-flip;
|
||||||
|
animation-name: fa-flip;
|
||||||
|
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
-webkit-animation-timing-function: var(--fa-animation-timing, ease-in-out);
|
||||||
|
animation-timing-function: var(--fa-animation-timing, ease-in-out); }
|
||||||
|
|
||||||
|
.fa-shake {
|
||||||
|
-webkit-animation-name: fa-shake;
|
||||||
|
animation-name: fa-shake;
|
||||||
|
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
-webkit-animation-timing-function: var(--fa-animation-timing, linear);
|
||||||
|
animation-timing-function: var(--fa-animation-timing, linear); }
|
||||||
|
|
||||||
|
.fa-spin {
|
||||||
|
-webkit-animation-name: fa-spin;
|
||||||
|
animation-name: fa-spin;
|
||||||
|
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
animation-delay: var(--fa-animation-delay, 0s);
|
||||||
|
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
-webkit-animation-duration: var(--fa-animation-duration, 2s);
|
||||||
|
animation-duration: var(--fa-animation-duration, 2s);
|
||||||
|
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
-webkit-animation-timing-function: var(--fa-animation-timing, linear);
|
||||||
|
animation-timing-function: var(--fa-animation-timing, linear); }
|
||||||
|
|
||||||
|
.fa-spin-reverse {
|
||||||
|
--fa-animation-direction: reverse; }
|
||||||
|
|
||||||
|
.fa-pulse,
|
||||||
|
.fa-spin-pulse {
|
||||||
|
-webkit-animation-name: fa-spin;
|
||||||
|
animation-name: fa-spin;
|
||||||
|
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
animation-direction: var(--fa-animation-direction, normal);
|
||||||
|
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
animation-duration: var(--fa-animation-duration, 1s);
|
||||||
|
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||||
|
-webkit-animation-timing-function: var(--fa-animation-timing, steps(8));
|
||||||
|
animation-timing-function: var(--fa-animation-timing, steps(8)); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fa-beat,
|
||||||
|
.fa-bounce,
|
||||||
|
.fa-fade,
|
||||||
|
.fa-beat-fade,
|
||||||
|
.fa-flip,
|
||||||
|
.fa-pulse,
|
||||||
|
.fa-shake,
|
||||||
|
.fa-spin,
|
||||||
|
.fa-spin-pulse {
|
||||||
|
-webkit-animation-delay: -1ms;
|
||||||
|
animation-delay: -1ms;
|
||||||
|
-webkit-animation-duration: 1ms;
|
||||||
|
animation-duration: 1ms;
|
||||||
|
-webkit-animation-iteration-count: 1;
|
||||||
|
animation-iteration-count: 1;
|
||||||
|
-webkit-transition-delay: 0s;
|
||||||
|
transition-delay: 0s;
|
||||||
|
-webkit-transition-duration: 0s;
|
||||||
|
transition-duration: 0s; } }
|
||||||
|
|
||||||
|
@-webkit-keyframes fa-beat {
|
||||||
|
0%, 90% {
|
||||||
|
-webkit-transform: scale(1);
|
||||||
|
transform: scale(1); }
|
||||||
|
45% {
|
||||||
|
-webkit-transform: scale(var(--fa-beat-scale, 1.25));
|
||||||
|
transform: scale(var(--fa-beat-scale, 1.25)); } }
|
||||||
|
|
||||||
|
@keyframes fa-beat {
|
||||||
|
0%, 90% {
|
||||||
|
-webkit-transform: scale(1);
|
||||||
|
transform: scale(1); }
|
||||||
|
45% {
|
||||||
|
-webkit-transform: scale(var(--fa-beat-scale, 1.25));
|
||||||
|
transform: scale(var(--fa-beat-scale, 1.25)); } }
|
||||||
|
|
||||||
|
@-webkit-keyframes fa-bounce {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: scale(1, 1) translateY(0);
|
||||||
|
transform: scale(1, 1) translateY(0); }
|
||||||
|
10% {
|
||||||
|
-webkit-transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0);
|
||||||
|
transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); }
|
||||||
|
30% {
|
||||||
|
-webkit-transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em));
|
||||||
|
transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); }
|
||||||
|
50% {
|
||||||
|
-webkit-transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0);
|
||||||
|
transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); }
|
||||||
|
57% {
|
||||||
|
-webkit-transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em));
|
||||||
|
transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); }
|
||||||
|
64% {
|
||||||
|
-webkit-transform: scale(1, 1) translateY(0);
|
||||||
|
transform: scale(1, 1) translateY(0); }
|
||||||
|
100% {
|
||||||
|
-webkit-transform: scale(1, 1) translateY(0);
|
||||||
|
transform: scale(1, 1) translateY(0); } }
|
||||||
|
|
||||||
|
@keyframes fa-bounce {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: scale(1, 1) translateY(0);
|
||||||
|
transform: scale(1, 1) translateY(0); }
|
||||||
|
10% {
|
||||||
|
-webkit-transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0);
|
||||||
|
transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); }
|
||||||
|
30% {
|
||||||
|
-webkit-transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em));
|
||||||
|
transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); }
|
||||||
|
50% {
|
||||||
|
-webkit-transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0);
|
||||||
|
transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); }
|
||||||
|
57% {
|
||||||
|
-webkit-transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em));
|
||||||
|
transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); }
|
||||||
|
64% {
|
||||||
|
-webkit-transform: scale(1, 1) translateY(0);
|
||||||
|
transform: scale(1, 1) translateY(0); }
|
||||||
|
100% {
|
||||||
|
-webkit-transform: scale(1, 1) translateY(0);
|
||||||
|
transform: scale(1, 1) translateY(0); } }
|
||||||
|
|
||||||
|
@-webkit-keyframes fa-fade {
|
||||||
|
50% {
|
||||||
|
opacity: var(--fa-fade-opacity, 0.4); } }
|
||||||
|
|
||||||
|
@keyframes fa-fade {
|
||||||
|
50% {
|
||||||
|
opacity: var(--fa-fade-opacity, 0.4); } }
|
||||||
|
|
||||||
|
@-webkit-keyframes fa-beat-fade {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: var(--fa-beat-fade-opacity, 0.4);
|
||||||
|
-webkit-transform: scale(1);
|
||||||
|
transform: scale(1); }
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: scale(var(--fa-beat-fade-scale, 1.125));
|
||||||
|
transform: scale(var(--fa-beat-fade-scale, 1.125)); } }
|
||||||
|
|
||||||
|
@keyframes fa-beat-fade {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: var(--fa-beat-fade-opacity, 0.4);
|
||||||
|
-webkit-transform: scale(1);
|
||||||
|
transform: scale(1); }
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: scale(var(--fa-beat-fade-scale, 1.125));
|
||||||
|
transform: scale(var(--fa-beat-fade-scale, 1.125)); } }
|
||||||
|
|
||||||
|
@-webkit-keyframes fa-flip {
|
||||||
|
50% {
|
||||||
|
-webkit-transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg));
|
||||||
|
transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); } }
|
||||||
|
|
||||||
|
@keyframes fa-flip {
|
||||||
|
50% {
|
||||||
|
-webkit-transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg));
|
||||||
|
transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); } }
|
||||||
|
|
||||||
|
@-webkit-keyframes fa-shake {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(-15deg);
|
||||||
|
transform: rotate(-15deg); }
|
||||||
|
4% {
|
||||||
|
-webkit-transform: rotate(15deg);
|
||||||
|
transform: rotate(15deg); }
|
||||||
|
8%, 24% {
|
||||||
|
-webkit-transform: rotate(-18deg);
|
||||||
|
transform: rotate(-18deg); }
|
||||||
|
12%, 28% {
|
||||||
|
-webkit-transform: rotate(18deg);
|
||||||
|
transform: rotate(18deg); }
|
||||||
|
16% {
|
||||||
|
-webkit-transform: rotate(-22deg);
|
||||||
|
transform: rotate(-22deg); }
|
||||||
|
20% {
|
||||||
|
-webkit-transform: rotate(22deg);
|
||||||
|
transform: rotate(22deg); }
|
||||||
|
32% {
|
||||||
|
-webkit-transform: rotate(-12deg);
|
||||||
|
transform: rotate(-12deg); }
|
||||||
|
36% {
|
||||||
|
-webkit-transform: rotate(12deg);
|
||||||
|
transform: rotate(12deg); }
|
||||||
|
40%, 100% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg); } }
|
||||||
|
|
||||||
|
@keyframes fa-shake {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(-15deg);
|
||||||
|
transform: rotate(-15deg); }
|
||||||
|
4% {
|
||||||
|
-webkit-transform: rotate(15deg);
|
||||||
|
transform: rotate(15deg); }
|
||||||
|
8%, 24% {
|
||||||
|
-webkit-transform: rotate(-18deg);
|
||||||
|
transform: rotate(-18deg); }
|
||||||
|
12%, 28% {
|
||||||
|
-webkit-transform: rotate(18deg);
|
||||||
|
transform: rotate(18deg); }
|
||||||
|
16% {
|
||||||
|
-webkit-transform: rotate(-22deg);
|
||||||
|
transform: rotate(-22deg); }
|
||||||
|
20% {
|
||||||
|
-webkit-transform: rotate(22deg);
|
||||||
|
transform: rotate(22deg); }
|
||||||
|
32% {
|
||||||
|
-webkit-transform: rotate(-12deg);
|
||||||
|
transform: rotate(-12deg); }
|
||||||
|
36% {
|
||||||
|
-webkit-transform: rotate(12deg);
|
||||||
|
transform: rotate(12deg); }
|
||||||
|
40%, 100% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg); } }
|
||||||
|
|
||||||
|
@-webkit-keyframes fa-spin {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg); }
|
||||||
|
100% {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
@keyframes fa-spin {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg); }
|
||||||
|
100% {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.fa-rotate-90 {
|
||||||
|
-webkit-transform: rotate(90deg);
|
||||||
|
transform: rotate(90deg); }
|
||||||
|
|
||||||
|
.fa-rotate-180 {
|
||||||
|
-webkit-transform: rotate(180deg);
|
||||||
|
transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.fa-rotate-270 {
|
||||||
|
-webkit-transform: rotate(270deg);
|
||||||
|
transform: rotate(270deg); }
|
||||||
|
|
||||||
|
.fa-flip-horizontal {
|
||||||
|
-webkit-transform: scale(-1, 1);
|
||||||
|
transform: scale(-1, 1); }
|
||||||
|
|
||||||
|
.fa-flip-vertical {
|
||||||
|
-webkit-transform: scale(1, -1);
|
||||||
|
transform: scale(1, -1); }
|
||||||
|
|
||||||
|
.fa-flip-both,
|
||||||
|
.fa-flip-horizontal.fa-flip-vertical {
|
||||||
|
-webkit-transform: scale(-1, -1);
|
||||||
|
transform: scale(-1, -1); }
|
||||||
|
|
||||||
|
.fa-rotate-by {
|
||||||
|
-webkit-transform: rotate(var(--fa-rotate-angle, 0));
|
||||||
|
transform: rotate(var(--fa-rotate-angle, 0)); }
|
||||||
|
|
||||||
|
.fa-stack {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
height: 2em;
|
||||||
|
position: relative;
|
||||||
|
width: 2.5em; }
|
||||||
|
|
||||||
|
.fa-stack-1x,
|
||||||
|
.fa-stack-2x {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: auto;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: var(--fa-stack-z-index, auto); }
|
||||||
|
|
||||||
|
.svg-inline--fa.fa-stack-1x {
|
||||||
|
height: 1em;
|
||||||
|
width: 1.25em; }
|
||||||
|
|
||||||
|
.svg-inline--fa.fa-stack-2x {
|
||||||
|
height: 2em;
|
||||||
|
width: 2.5em; }
|
||||||
|
|
||||||
|
.fa-inverse {
|
||||||
|
color: var(--fa-inverse, #fff); }
|
||||||
|
|
||||||
|
.sr-only,
|
||||||
|
.fa-sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0; }
|
||||||
|
|
||||||
|
.sr-only-focusable:not(:focus),
|
||||||
|
.fa-sr-only-focusable:not(:focus) {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0; }
|
||||||
|
|
||||||
|
.svg-inline--fa .fa-primary {
|
||||||
|
fill: var(--fa-primary-color, currentColor);
|
||||||
|
opacity: var(--fa-primary-opacity, 1); }
|
||||||
|
|
||||||
|
.svg-inline--fa .fa-secondary {
|
||||||
|
fill: var(--fa-secondary-color, currentColor);
|
||||||
|
opacity: var(--fa-secondary-opacity, 0.4); }
|
||||||
|
|
||||||
|
.svg-inline--fa.fa-swap-opacity .fa-primary {
|
||||||
|
opacity: var(--fa-secondary-opacity, 0.4); }
|
||||||
|
|
||||||
|
.svg-inline--fa.fa-swap-opacity .fa-secondary {
|
||||||
|
opacity: var(--fa-primary-opacity, 1); }
|
||||||
|
|
||||||
|
.svg-inline--fa mask .fa-primary,
|
||||||
|
.svg-inline--fa mask .fa-secondary {
|
||||||
|
fill: black; }
|
||||||
|
|
||||||
|
.fad.fa-inverse,
|
||||||
|
.fa-duotone.fa-inverse {
|
||||||
|
color: var(--fa-inverse, #fff); }
|
||||||
6
static/fontawesome/css/svg-with-js.min.css
vendored
Normal file
6
static/fontawesome/css/svg-with-js.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
26
static/fontawesome/css/v4-font-face.css
vendored
Normal file
26
static/fontawesome/css/v4-font-face.css
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||||
|
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
* Copyright 2024 Fonticons, Inc.
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'FontAwesome';
|
||||||
|
font-display: block;
|
||||||
|
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); }
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'FontAwesome';
|
||||||
|
font-display: block;
|
||||||
|
src: url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.ttf") format("truetype"); }
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'FontAwesome';
|
||||||
|
font-display: block;
|
||||||
|
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype");
|
||||||
|
unicode-range: U+F003,U+F006,U+F014,U+F016-F017,U+F01A-F01B,U+F01D,U+F022,U+F03E,U+F044,U+F046,U+F05C-F05D,U+F06E,U+F070,U+F087-F088,U+F08A,U+F094,U+F096-F097,U+F09D,U+F0A0,U+F0A2,U+F0A4-F0A7,U+F0C5,U+F0C7,U+F0E5-F0E6,U+F0EB,U+F0F6-F0F8,U+F10C,U+F114-F115,U+F118-F11A,U+F11C-F11D,U+F133,U+F147,U+F14E,U+F150-F152,U+F185-F186,U+F18E,U+F190-F192,U+F196,U+F1C1-F1C9,U+F1D9,U+F1DB,U+F1E3,U+F1EA,U+F1F7,U+F1F9,U+F20A,U+F247-F248,U+F24A,U+F24D,U+F255-F25B,U+F25D,U+F271-F274,U+F278,U+F27B,U+F28C,U+F28E,U+F29C,U+F2B5,U+F2B7,U+F2BA,U+F2BC,U+F2BE,U+F2C0-F2C1,U+F2C3,U+F2D0,U+F2D2,U+F2D4,U+F2DC; }
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'FontAwesome';
|
||||||
|
font-display: block;
|
||||||
|
src: url("../webfonts/fa-v4compatibility.woff2") format("woff2"), url("../webfonts/fa-v4compatibility.ttf") format("truetype");
|
||||||
|
unicode-range: U+F041,U+F047,U+F065-F066,U+F07D-F07E,U+F080,U+F08B,U+F08E,U+F090,U+F09A,U+F0AC,U+F0AE,U+F0B2,U+F0D0,U+F0D6,U+F0E4,U+F0EC,U+F10A-F10B,U+F123,U+F13E,U+F148-F149,U+F14C,U+F156,U+F15E,U+F160-F161,U+F163,U+F175-F178,U+F195,U+F1F8,U+F219,U+F27A; }
|
||||||
6
static/fontawesome/css/v4-font-face.min.css
vendored
Normal file
6
static/fontawesome/css/v4-font-face.min.css
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||||
|
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
* Copyright 2024 Fonticons, Inc.
|
||||||
|
*/
|
||||||
|
@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a}
|
||||||
2194
static/fontawesome/css/v4-shims.css
vendored
Normal file
2194
static/fontawesome/css/v4-shims.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
6
static/fontawesome/css/v4-shims.min.css
vendored
Normal file
6
static/fontawesome/css/v4-shims.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
22
static/fontawesome/css/v5-font-face.css
vendored
Normal file
22
static/fontawesome/css/v5-font-face.css
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||||
|
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
* Copyright 2024 Fonticons, Inc.
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Font Awesome 5 Brands';
|
||||||
|
font-display: block;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.ttf") format("truetype"); }
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Font Awesome 5 Free';
|
||||||
|
font-display: block;
|
||||||
|
font-weight: 900;
|
||||||
|
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); }
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Font Awesome 5 Free';
|
||||||
|
font-display: block;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); }
|
||||||
6
static/fontawesome/css/v5-font-face.min.css
vendored
Normal file
6
static/fontawesome/css/v5-font-face.min.css
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||||
|
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
* Copyright 2024 Fonticons, Inc.
|
||||||
|
*/
|
||||||
|
@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}
|
||||||
BIN
static/fontawesome/webfonts/fa-brands-400.ttf
Normal file
BIN
static/fontawesome/webfonts/fa-brands-400.ttf
Normal file
Binary file not shown.
BIN
static/fontawesome/webfonts/fa-brands-400.woff2
Normal file
BIN
static/fontawesome/webfonts/fa-brands-400.woff2
Normal file
Binary file not shown.
BIN
static/fontawesome/webfonts/fa-regular-400.ttf
Normal file
BIN
static/fontawesome/webfonts/fa-regular-400.ttf
Normal file
Binary file not shown.
BIN
static/fontawesome/webfonts/fa-regular-400.woff2
Normal file
BIN
static/fontawesome/webfonts/fa-regular-400.woff2
Normal file
Binary file not shown.
BIN
static/fontawesome/webfonts/fa-solid-900.ttf
Normal file
BIN
static/fontawesome/webfonts/fa-solid-900.ttf
Normal file
Binary file not shown.
BIN
static/fontawesome/webfonts/fa-solid-900.woff2
Normal file
BIN
static/fontawesome/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
BIN
static/fontawesome/webfonts/fa-v4compatibility.ttf
Normal file
BIN
static/fontawesome/webfonts/fa-v4compatibility.ttf
Normal file
Binary file not shown.
BIN
static/fontawesome/webfonts/fa-v4compatibility.woff2
Normal file
BIN
static/fontawesome/webfonts/fa-v4compatibility.woff2
Normal file
Binary file not shown.
BIN
static/images/ata_logo.png
Normal file
BIN
static/images/ata_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
static/images/favicon.ico
Normal file
BIN
static/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
static/images/helio-posh.png
Normal file
BIN
static/images/helio-posh.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
static/images/internet_icon.png
Normal file
BIN
static/images/internet_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
static/images/switch-icon.png
Normal file
BIN
static/images/switch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
1
static/js/crypto-js.min.js
vendored
Normal file
1
static/js/crypto-js.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
29
static/js/data-request.js
Normal file
29
static/js/data-request.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
function requestJsonFromServer(route) {
|
||||||
|
return fetch(route)
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
return response.json(); // Assuming the server returns a JSON response
|
||||||
|
})
|
||||||
|
.then(jsonData => {
|
||||||
|
//console.log('Received JSON data:', jsonData);
|
||||||
|
return jsonData; // Return the JSON data
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('There was a problem with the fetch operation:', error);
|
||||||
|
return null; // Return null in case of an error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Example usage
|
||||||
|
requestJsonFromServer('/get_data')
|
||||||
|
.then(data => {
|
||||||
|
if (data !== null) {
|
||||||
|
console.log("Data received:", data);
|
||||||
|
// You can process the data here as needed
|
||||||
|
} else {
|
||||||
|
console.log("Failed to retrieve data from the server.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*/
|
||||||
10716
static/js/jquery-3.7.1.js
vendored
Normal file
10716
static/js/jquery-3.7.1.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
497
temp/gui_psg.py
Normal file
497
temp/gui_psg.py
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
"""PySimpleGUI-based desktop GUI for DslrDirector.
|
||||||
|
|
||||||
|
This file provides a simpler, single-file GUI implementation using PySimpleGUI
|
||||||
|
instead of Tkinter. It mirrors the core functionality: Animations tab (home,
|
||||||
|
countdown, sharing), Communication tab for BLE controls (scan/connect/disconnect),
|
||||||
|
and a Logs tab with a refresh button.
|
||||||
|
|
||||||
|
It reuses the project's existing persistence helpers (load_animation, save_animation,
|
||||||
|
load_comm, save_comm) and imports the optional BleComm module if available.
|
||||||
|
|
||||||
|
Logo handling: use Pillow (if available) to apply the same un-matte/defringe and
|
||||||
|
30%-scale pipeline. On Windows the code will compose the processed image onto the
|
||||||
|
panel background color and save as data/ata_logo_out.png so that the displayed
|
||||||
|
image has no halo issues.
|
||||||
|
|
||||||
|
Run with: python src/gui_psg.py (from project root, venv recommended)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import PySimpleGUI as sg
|
||||||
|
# Some PySimpleGUI builds (private PyPI message) may present an unexpected API.
|
||||||
|
# Verify the module exposes the expected elements; otherwise fall back to the
|
||||||
|
# existing Tkinter GUI so the user still gets a working UI and gets guidance.
|
||||||
|
try:
|
||||||
|
if not hasattr(sg, 'Text') or not hasattr(sg, 'Window'):
|
||||||
|
raise ImportError('PySimpleGUI API appears incompatible')
|
||||||
|
except Exception as _psg_err:
|
||||||
|
msg = (
|
||||||
|
"PySimpleGUI import succeeded but the installed package does not expose the\n"
|
||||||
|
"expected API (Text/Window). This can happen with a private or mismatched\n"
|
||||||
|
"PySimpleGUI build.\n\n"
|
||||||
|
"Recommended fix:\n"
|
||||||
|
" python -m pip uninstall PySimpleGUI\n"
|
||||||
|
" python -m pip cache purge\n"
|
||||||
|
" python -m pip install --upgrade --extra-index-url https://PySimpleGUI.net/install PySimpleGUI\n\n"
|
||||||
|
"Falling back to the Tkinter GUI (src/gui.py) for now...\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# write to log if available
|
||||||
|
with open(os.path.join(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')), 'data', 'gui_start.log'), 'a', encoding='utf-8') as lf:
|
||||||
|
lf.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} PSG: {msg}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print(msg)
|
||||||
|
try:
|
||||||
|
# Ensure repo root is on sys.path so 'src' package imports work when running as a script
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
if repo_root not in sys.path:
|
||||||
|
sys.path.insert(0, repo_root)
|
||||||
|
# Launch existing Tk GUI as a fallback
|
||||||
|
from src.gui import DSLRGui
|
||||||
|
app = DSLRGui()
|
||||||
|
app.mainloop()
|
||||||
|
# clean exit
|
||||||
|
raise SystemExit(0)
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# if fallback also fails, log and re-raise
|
||||||
|
try:
|
||||||
|
with open(os.path.join(repo_root, 'data', 'gui_start.log'), 'a', encoding='utf-8') as lf:
|
||||||
|
lf.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} PSG fallback error: {e}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
# reuse helpers from existing module
|
||||||
|
try:
|
||||||
|
import src.app as appmod
|
||||||
|
import importlib
|
||||||
|
BleComm = importlib.import_module('src.BleComm')
|
||||||
|
except Exception:
|
||||||
|
appmod = None
|
||||||
|
BleComm = None
|
||||||
|
|
||||||
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
DATA_DIR = os.path.join(REPO_ROOT, 'data')
|
||||||
|
CONFIG_PATH = os.path.join(DATA_DIR, 'config.json')
|
||||||
|
COMM_PATH = os.path.join(DATA_DIR, 'comm.json')
|
||||||
|
GUI_LOG = os.path.join(REPO_ROOT, 'data', 'gui_start.log')
|
||||||
|
|
||||||
|
|
||||||
|
def _write_gui_log(msg: str):
|
||||||
|
try:
|
||||||
|
with open(GUI_LOG, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# reuse persistence helpers if available in appmod by importing functions from gui.py
|
||||||
|
try:
|
||||||
|
from src.gui import load_animation, save_animation, load_comm, save_comm
|
||||||
|
except Exception:
|
||||||
|
# fallback implementations (simple read/write)
|
||||||
|
def load_animation():
|
||||||
|
try:
|
||||||
|
if not os.path.exists(CONFIG_PATH):
|
||||||
|
return {}
|
||||||
|
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f) or {}
|
||||||
|
return data.get('animation-events', {})
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_animation(ae: dict):
|
||||||
|
try:
|
||||||
|
base = {}
|
||||||
|
if os.path.exists(CONFIG_PATH):
|
||||||
|
try:
|
||||||
|
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
base = json.load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
base = {}
|
||||||
|
base['animation-events'] = ae
|
||||||
|
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(base, f, indent=2)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def load_comm():
|
||||||
|
try:
|
||||||
|
if not os.path.exists(COMM_PATH):
|
||||||
|
return {}
|
||||||
|
with open(COMM_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_comm(cfg: dict):
|
||||||
|
try:
|
||||||
|
with open(COMM_PATH, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(cfg, f, indent=2)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Image processing pipeline (Pillow optional)
|
||||||
|
PIL_AVAILABLE = False
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
PIL_AVAILABLE = True
|
||||||
|
except Exception:
|
||||||
|
Image = None
|
||||||
|
|
||||||
|
|
||||||
|
def process_logo_for_psg(path: str, panel_hex='#252535'):
|
||||||
|
"""Open path, un-matte/defringe, resize to 30%, and return a filepath to a PNG.
|
||||||
|
|
||||||
|
On Windows compose onto panel background and save to DATA_DIR/ata_logo_out.png
|
||||||
|
and return that path. If Pillow not available, return original path.
|
||||||
|
"""
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
if not PIL_AVAILABLE:
|
||||||
|
return path
|
||||||
|
try:
|
||||||
|
img = Image.open(path).convert('RGBA')
|
||||||
|
# crop transparent border
|
||||||
|
try:
|
||||||
|
alpha = img.getchannel('A')
|
||||||
|
bbox = alpha.getbbox()
|
||||||
|
if bbox:
|
||||||
|
img = img.crop(bbox)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# (simple) un-matte: sample border matte and reverse for semi-alpha
|
||||||
|
try:
|
||||||
|
alpha = img.getchannel('A')
|
||||||
|
w, h = img.size
|
||||||
|
px = img.load()
|
||||||
|
matte_samples = []
|
||||||
|
for x in range(w):
|
||||||
|
if alpha.getpixel((x, 0)) == 0:
|
||||||
|
matte_samples.append(px[x, 0][:3])
|
||||||
|
if alpha.getpixel((x, h - 1)) == 0:
|
||||||
|
matte_samples.append(px[x, h - 1][:3])
|
||||||
|
for y in range(h):
|
||||||
|
if alpha.getpixel((0, y)) == 0:
|
||||||
|
matte_samples.append(px[0, y][:3])
|
||||||
|
if alpha.getpixel((w - 1, y)) == 0:
|
||||||
|
matte_samples.append(px[w - 1, y][:3])
|
||||||
|
if matte_samples:
|
||||||
|
mr = int(sum(s[0] for s in matte_samples) / len(matte_samples))
|
||||||
|
mg = int(sum(s[1] for s in matte_samples) / len(matte_samples))
|
||||||
|
mb = int(sum(s[2] for s in matte_samples) / len(matte_samples))
|
||||||
|
matte = (mr, mg, mb)
|
||||||
|
new = Image.new('RGBA', img.size)
|
||||||
|
for yy in range(h):
|
||||||
|
for xx in range(w):
|
||||||
|
r, g, b, a = img.getpixel((xx, yy))
|
||||||
|
if a == 0 or a == 255:
|
||||||
|
new.putpixel((xx, yy), (r, g, b, a))
|
||||||
|
else:
|
||||||
|
af = a / 255.0
|
||||||
|
rr = int(round((r - (1 - af) * matte[0]) / max(af, 1e-6)))
|
||||||
|
gg = int(round((g - (1 - af) * matte[1]) / max(af, 1e-6)))
|
||||||
|
bb = int(round((b - (1 - af) * matte[2]) / max(af, 1e-6)))
|
||||||
|
rr = max(0, min(255, rr))
|
||||||
|
gg = max(0, min(255, gg))
|
||||||
|
bb = max(0, min(255, bb))
|
||||||
|
new.putpixel((xx, yy), (rr, gg, bb, a))
|
||||||
|
img = new
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# conservative defringe
|
||||||
|
try:
|
||||||
|
alpha_cut = 32
|
||||||
|
color_tol = 30
|
||||||
|
w2, h2 = img.size
|
||||||
|
px2 = img.load()
|
||||||
|
matte_rgb = None
|
||||||
|
if hasattr(img, 'info') and 'background' in img.info:
|
||||||
|
matte_rgb = img.info['background']
|
||||||
|
if matte_rgb is None and len(matte_samples) > 0:
|
||||||
|
matte_rgb = matte
|
||||||
|
if matte_rgb is None:
|
||||||
|
matte_rgb = (30, 30, 43)
|
||||||
|
removed = 0
|
||||||
|
for yy in range(h2):
|
||||||
|
for xx in range(w2):
|
||||||
|
r, g, b, a = px2[xx, yy]
|
||||||
|
if 0 < a <= alpha_cut:
|
||||||
|
dr = r - matte_rgb[0]
|
||||||
|
dg = g - matte_rgb[1]
|
||||||
|
db = b - matte_rgb[2]
|
||||||
|
dist = (dr * dr + dg * dg + db * db) ** 0.5
|
||||||
|
if dist <= color_tol:
|
||||||
|
px2[xx, yy] = (0, 0, 0, 0)
|
||||||
|
removed += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# resize to 30%
|
||||||
|
sw, sh = img.size
|
||||||
|
new_w = max(1, int(sw * 0.3))
|
||||||
|
new_h = max(1, int(sh * 0.3))
|
||||||
|
img_resized = img.resize((new_w, new_h), Image.LANCZOS)
|
||||||
|
# On Windows, compose to opaque background and save
|
||||||
|
out_path = os.path.join(DATA_DIR, 'ata_logo_out.png')
|
||||||
|
try:
|
||||||
|
if os.name == 'nt':
|
||||||
|
def hex_to_rgb(h):
|
||||||
|
h = h.lstrip('#')
|
||||||
|
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
||||||
|
try:
|
||||||
|
panel_rgb = hex_to_rgb(panel_hex)
|
||||||
|
except Exception:
|
||||||
|
panel_rgb = (37, 37, 53)
|
||||||
|
bg = Image.new('RGBA', img_resized.size, panel_rgb + (255,))
|
||||||
|
composed = Image.alpha_composite(bg, img_resized)
|
||||||
|
composed.save(out_path, format='PNG')
|
||||||
|
_write_gui_log(f'PSG Logo: wrote composed PNG to {out_path}')
|
||||||
|
return out_path
|
||||||
|
else:
|
||||||
|
img_resized.save(out_path, format='PNG')
|
||||||
|
_write_gui_log(f'PSG Logo: wrote PNG to {out_path}')
|
||||||
|
return out_path
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Cosmetic theme/colors for a modern dark look
|
||||||
|
def _apply_psg_theme():
|
||||||
|
# define core palette
|
||||||
|
bg = '#12121a'
|
||||||
|
panel = '#1e1e2b'
|
||||||
|
fg = '#e6e6f0'
|
||||||
|
accent = '#4fb0c6'
|
||||||
|
|
||||||
|
# Add a named theme mapping to PySimpleGUI
|
||||||
|
try:
|
||||||
|
sg.LOOK_AND_FEEL_TABLE['DslrDark'] = {
|
||||||
|
'BACKGROUND': bg,
|
||||||
|
'TEXT': fg,
|
||||||
|
'INPUT': '#262636',
|
||||||
|
'TEXT_INPUT': fg,
|
||||||
|
'SCROLL': '#2b2b36',
|
||||||
|
'BUTTON': accent,
|
||||||
|
'PROGRESS': ('#01826B', '#D0D0D0'),
|
||||||
|
'BORDER': 1,
|
||||||
|
'SLIDER_DEPTH': 0,
|
||||||
|
'PROGRESS_DEPTH': 0
|
||||||
|
}
|
||||||
|
sg.theme('DslrDark')
|
||||||
|
sg.set_options(element_padding=(6, 6), button_color=(fg, panel), font=('Segoe UI', 11))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
sg.theme('Dark')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_apply_psg_theme()
|
||||||
|
|
||||||
|
|
||||||
|
# Build the PySimpleGUI layout
|
||||||
|
def build_layout():
|
||||||
|
anim = load_animation()
|
||||||
|
comm = load_comm()
|
||||||
|
# Anim tab layout
|
||||||
|
anim_layout = [
|
||||||
|
[sg.Text('Home')],
|
||||||
|
[sg.Radio('Animation', 'home_mode', default=(anim.get('home-state', 'animation') == 'animation'), key='-HOME_ANIM-'),
|
||||||
|
sg.Radio('Solid', 'home_mode', default=(anim.get('home-state') == 'solid'), key='-HOME_SOLID-')],
|
||||||
|
[sg.Text('Anim ID'), sg.Input(anim.get('home-anim', '0'), key='-HOME_ID-', size=(8,1))],
|
||||||
|
[sg.Text('Color'), sg.Input(anim.get('home-color', '#000000'), key='-HOME_COLOR-', size=(12,1)), sg.Button('Pick', key='-HOME_PICK-')],
|
||||||
|
[sg.HorizontalSeparator()],
|
||||||
|
[sg.Text('Countdown')],
|
||||||
|
[sg.Radio('Animation', 'count_mode', default=(anim.get('countdown-state', 'animation') == 'animation'), key='-COUNT_ANIM-'),
|
||||||
|
sg.Radio('Solid', 'count_mode', default=(anim.get('countdown-state') == 'solid'), key='-COUNT_SOLID-')],
|
||||||
|
[sg.Text('Anim ID'), sg.Input(anim.get('countdown-anim', '0'), key='-COUNT_ID-', size=(8,1))],
|
||||||
|
[sg.Text('Color'), sg.Input(anim.get('countdown-color', '#ffffff'), key='-COUNT_COLOR-', size=(12,1)), sg.Button('Pick', key='-COUNT_PICK-')],
|
||||||
|
[sg.HorizontalSeparator()],
|
||||||
|
[sg.Text('Sharing')],
|
||||||
|
[sg.Radio('Animation', 'share_mode', default=(anim.get('sharing-state', 'animation') == 'animation'), key='-SHARE_ANIM-'),
|
||||||
|
sg.Radio('Solid', 'share_mode', default=(anim.get('sharing-state') == 'solid'), key='-SHARE_SOLID-')],
|
||||||
|
[sg.Text('Anim ID'), sg.Input(anim.get('sharing-anim', '0'), key='-SHARE_ID-', size=(8,1))],
|
||||||
|
[sg.Text('Color'), sg.Input(anim.get('sharing-color', '#0000ff'), key='-SHARE_COLOR-', size=(12,1)), sg.Button('Pick', key='-SHARE_PICK-')],
|
||||||
|
[sg.Button('Save Animations', key='-SAVE_ANIM-')]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Comm (BLE) layout
|
||||||
|
comm_name = comm.get('ble', {}).get('device_name', '') if isinstance(comm, dict) else ''
|
||||||
|
comm_addr = comm.get('ble', {}).get('address', '') if isinstance(comm, dict) else ''
|
||||||
|
comm_filter = comm.get('ble', {}).get('filter_name', '') if isinstance(comm, dict) else ''
|
||||||
|
comm_layout = [
|
||||||
|
[sg.Text('Device Name'), sg.Input(comm_name, key='-COMM_NAME-', size=(30,1))],
|
||||||
|
[sg.Text('Address/ID'), sg.Input(comm_addr, key='-COMM_ADDR-', size=(30,1))],
|
||||||
|
[sg.Text('Filter'), sg.Input(comm_filter, key='-COMM_FILTER-', size=(30,1))],
|
||||||
|
[sg.Button('Scan', key='-SCAN-', button_color=('white', '#2f313a'), size=(10,1)),
|
||||||
|
sg.Button('Connect', key='-CONNECT-', button_color=('white', '#4fb0c6'), size=(10,1)),
|
||||||
|
sg.Button('Disconnect', key='-DISCONNECT-', button_color=('white', '#a64b4b'), size=(10,1))],
|
||||||
|
[sg.Listbox(values=[], size=(60,6), key='-SCAN_LIST-')]
|
||||||
|
]
|
||||||
|
|
||||||
|
logs_layout = [
|
||||||
|
[sg.Multiline('', size=(80, 20), key='-LOG_TEXT-')],
|
||||||
|
[sg.Button('Refresh Logs', key='-REFRESH_LOGS-', button_color=('white', '#4fb0c6'))]
|
||||||
|
]
|
||||||
|
|
||||||
|
tab_group = [[sg.Tab('Animations', anim_layout), sg.Tab('Communication', comm_layout), sg.Tab('Logs', logs_layout)]]
|
||||||
|
|
||||||
|
# load logo (attempt processed path)
|
||||||
|
logo_candidates = [
|
||||||
|
os.path.join(REPO_ROOT, 'static', 'images', 'ata_logo.png'),
|
||||||
|
os.path.join(os.getcwd(), 'static', 'images', 'ata_logo.png'),
|
||||||
|
os.path.join(DATA_DIR, 'ata_logo.png')
|
||||||
|
]
|
||||||
|
logo_path = None
|
||||||
|
for p in logo_candidates:
|
||||||
|
if p and os.path.exists(p):
|
||||||
|
logo_path = p
|
||||||
|
break
|
||||||
|
if logo_path:
|
||||||
|
processed = process_logo_for_psg(logo_path)
|
||||||
|
else:
|
||||||
|
processed = None
|
||||||
|
|
||||||
|
# Header: left logo, centered title/subtitle
|
||||||
|
if processed and os.path.exists(processed):
|
||||||
|
logo_el = sg.Image(processed, pad=(6, 6))
|
||||||
|
else:
|
||||||
|
logo_el = sg.Text('', size=(12, 1))
|
||||||
|
|
||||||
|
title_col = [[sg.Text('DSLR Director', font=('Segoe UI', 22, 'bold'), justification='center')],
|
||||||
|
[sg.Text('Control animations and BLE from your desktop', font=('Segoe UI', 10), text_color='#cbd6dc', justification='center')]]
|
||||||
|
|
||||||
|
header_frame = [
|
||||||
|
sg.Column([[logo_el]], vertical_alignment='center', element_justification='left', pad=(8, 8)),
|
||||||
|
sg.VerticalSeparator(),
|
||||||
|
sg.Column(title_col, vertical_alignment='center', element_justification='center', expand_x=True, pad=(8, 8))
|
||||||
|
]
|
||||||
|
|
||||||
|
# Slightly larger tab group with padding
|
||||||
|
layout = [
|
||||||
|
header_frame,
|
||||||
|
[sg.HorizontalSeparator()],
|
||||||
|
[sg.TabGroup(tab_group, key='-TABS-', expand_x=True, expand_y=True, pad=(6, 8))],
|
||||||
|
[sg.Button('Exit', button_color=('white', '#2f313a'))]
|
||||||
|
]
|
||||||
|
|
||||||
|
return layout
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
layout = build_layout()
|
||||||
|
window = sg.Window('DslrDirector - PySimpleGUI', layout, finalize=True)
|
||||||
|
|
||||||
|
# Background: attempt to start BleComm if present
|
||||||
|
try:
|
||||||
|
if BleComm is not None and hasattr(BleComm, 'start'):
|
||||||
|
BleComm.start()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
while True:
|
||||||
|
event, values = window.read(timeout=200)
|
||||||
|
if event == sg.WIN_CLOSED or event == 'Exit':
|
||||||
|
break
|
||||||
|
if event == '-SAVE_ANIM-':
|
||||||
|
ae = {}
|
||||||
|
ae['home-state'] = 'animation' if values.get('-HOME_ANIM-') else 'solid'
|
||||||
|
ae['home-color'] = values.get('-HOME_COLOR-', '#000000')
|
||||||
|
try:
|
||||||
|
ae['home-anim'] = int(values.get('-HOME_ID-', '0'))
|
||||||
|
except Exception:
|
||||||
|
ae['home-anim'] = 0
|
||||||
|
ae['countdown-state'] = 'animation' if values.get('-COUNT_ANIM-') else 'solid'
|
||||||
|
ae['countdown-color'] = values.get('-COUNT_COLOR-', '#ffffff')
|
||||||
|
try:
|
||||||
|
ae['countdown-anim'] = int(values.get('-COUNT_ID-', '0'))
|
||||||
|
except Exception:
|
||||||
|
ae['countdown-anim'] = 0
|
||||||
|
ae['sharing-state'] = 'animation' if values.get('-SHARE_ANIM-') else 'solid'
|
||||||
|
ae['sharing-color'] = values.get('-SHARE_COLOR-', '#0000ff')
|
||||||
|
try:
|
||||||
|
ae['sharing-anim'] = int(values.get('-SHARE_ID-', '0'))
|
||||||
|
except Exception:
|
||||||
|
ae['sharing-anim'] = 0
|
||||||
|
ok = save_animation(ae)
|
||||||
|
if ok:
|
||||||
|
sg.popup('Saved', 'Animations saved to config.json')
|
||||||
|
else:
|
||||||
|
sg.popup('Error', 'Failed to save animations')
|
||||||
|
|
||||||
|
if event == '-SCAN-':
|
||||||
|
window['-SCAN_LIST-'].update([])
|
||||||
|
def _scan():
|
||||||
|
try:
|
||||||
|
res = BleComm.scan(values.get('-COMM_FILTER-', '') if BleComm else [])
|
||||||
|
items = []
|
||||||
|
for d in res:
|
||||||
|
name = d.get('name') or ''
|
||||||
|
addr = d.get('address') or d.get('id') or ''
|
||||||
|
items.append(f"{name} | {addr}")
|
||||||
|
window['-SCAN_LIST-'].update(items)
|
||||||
|
except Exception as e:
|
||||||
|
window['-SCAN_LIST-'].update([f"Scan failed: {e}"])
|
||||||
|
threading.Thread(target=_scan, daemon=True).start()
|
||||||
|
|
||||||
|
if event == '-CONNECT-':
|
||||||
|
addr = values.get('-COMM_ADDR-', '').strip()
|
||||||
|
name = values.get('-COMM_NAME-', '').strip()
|
||||||
|
base = load_comm()
|
||||||
|
base['ble'] = base.get('ble', {})
|
||||||
|
if addr:
|
||||||
|
base['ble']['address'] = addr
|
||||||
|
if name:
|
||||||
|
base['ble']['device_name'] = name
|
||||||
|
base['ble']['filter_name'] = values.get('-COMM_FILTER-', '')
|
||||||
|
save_comm(base)
|
||||||
|
def _do():
|
||||||
|
try:
|
||||||
|
res = BleComm.connect(addr or name)
|
||||||
|
sg.popup('Connect', f'Connect result: {res}')
|
||||||
|
except Exception as e:
|
||||||
|
sg.popup('Connect failed', str(e))
|
||||||
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|
||||||
|
if event == '-DISCONNECT-':
|
||||||
|
def _do():
|
||||||
|
try:
|
||||||
|
res = BleComm.disconnect()
|
||||||
|
sg.popup('Disconnect', f'Disconnect result: {res}')
|
||||||
|
except Exception as e:
|
||||||
|
sg.popup('Disconnect failed', str(e))
|
||||||
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|
||||||
|
if event == '-REFRESH_LOGS-':
|
||||||
|
content = ''
|
||||||
|
if appmod is not None and hasattr(appmod, 'tail_log'):
|
||||||
|
content = appmod.tail_log(200)
|
||||||
|
else:
|
||||||
|
lf = os.path.join(REPO_ROOT, 'dslrbooth_triggers.log')
|
||||||
|
if os.path.exists(lf):
|
||||||
|
with open(lf, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
content = ''.join(f.readlines()[-200:])
|
||||||
|
window['-LOG_TEXT-'].update(content)
|
||||||
|
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
_write_gui_log('PSG GUI: starting')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
run()
|
||||||
370
templates/index.html
Normal file
370
templates/index.html
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Animation Config</title>
|
||||||
|
<link rel="stylesheet" href="/static/fontawesome/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0b0f14; --panel:#0f1720; --muted:#8b97a3; --accent:#7c3aed; --glass: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
/* fixed-width label used for the three left-aligned names */
|
||||||
|
.label-name{display:inline-block; min-width:140px; width:140px; text-align:left}
|
||||||
|
/* mode dropdown styling: dark background and light text */
|
||||||
|
.mode-select{margin-left:10px;padding:8px;border-radius:8px;background:#0b1218;color:#e6eef6;border:1px solid rgba(255,255,255,0.04);min-width:120px}
|
||||||
|
.mode-select:focus{outline:none; box-shadow:0 0 0 3px rgba(124,58,237,0.12)}
|
||||||
|
body{background:linear-gradient(180deg,#07090b 0%, #0b0f14 100%); color:#e6eef6; font-family:Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; margin:0;}
|
||||||
|
.container{max-width:920px; margin:36px auto; padding:28px; background:var(--panel); border-radius:12px; box-shadow:0 10px 30px rgba(2,6,23,0.6); display:grid; grid-template-columns:160px 1fr; gap:20px; align-items:start}
|
||||||
|
.container{position:relative}
|
||||||
|
.logo{display:flex; align-items:center; gap:10px}
|
||||||
|
.logo img{width:56px; height:56px; object-fit:contain; border-radius:8px}
|
||||||
|
h1{margin:0; font-size:20px}
|
||||||
|
p.lead{margin:6px 0 0; color:var(--muted); font-size:13px}
|
||||||
|
form{display:flex; flex-direction:column; gap:12px}
|
||||||
|
.field{display:flex; gap:12px; align-items:center}
|
||||||
|
label{flex:1; display:flex; gap:12px; align-items:center}
|
||||||
|
.field input[type=number]{width:6.5rem; padding:8px 10px; background:var(--glass); border:1px solid rgba(255,255,255,0.04); color:inherit; border-radius:8px}
|
||||||
|
.note{color:var(--muted); font-size:13px}
|
||||||
|
.row{display:flex; gap:12px}
|
||||||
|
.save{background:linear-gradient(90deg,var(--accent), #5b21b6); border:none; padding:10px 16px; color:white; border-radius:10px; cursor:pointer; display:inline-flex; gap:8px; align-items:center}
|
||||||
|
.card{background:linear-gradient(180deg, rgba(255,255,255,0.02), transparent); padding:14px; border-radius:10px}
|
||||||
|
/* tabs */
|
||||||
|
.tabs{display:flex;gap:12px;border-bottom:1px solid rgba(255,255,255,0.03);margin-bottom:12px}
|
||||||
|
.tab{padding:8px 12px;cursor:pointer;border-radius:8px 8px 0 0;background:transparent;color:var(--muted)}
|
||||||
|
.tab.active{background:linear-gradient(90deg,var(--accent), #5b21b6);color:white}
|
||||||
|
.tab-panel{display:none}
|
||||||
|
.tab-panel.active{display:block}
|
||||||
|
.meta{font-size:13px; color:var(--muted)}
|
||||||
|
/* modal styles for BLE scan */
|
||||||
|
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:none;align-items:center;justify-content:center;z-index:9999}
|
||||||
|
.modal{background:var(--panel);border-radius:10px;padding:16px;max-width:680px;width:90%;color:inherit}
|
||||||
|
.modal h3{margin:0 0 8px}
|
||||||
|
.device-list{max-height:320px;overflow:auto;margin-top:8px}
|
||||||
|
.device-item{padding:8px;border-radius:8px;cursor:pointer;border:1px solid rgba(255,255,255,0.03);margin:6px 0}
|
||||||
|
.device-item:hover{background:rgba(255,255,255,0.02)}
|
||||||
|
.modal-show{display:flex}
|
||||||
|
/* comm status top-right */
|
||||||
|
.comm-status{position:fixed;top:14px;right:18px;display:flex;align-items:center;gap:8px;background:rgba(11,18,24,0.85);padding:8px 12px;border-radius:10px;border:1px solid rgba(255,255,255,0.04);z-index:1200}
|
||||||
|
.comm-status .label{font-size:12px;color:var(--muted);margin-right:6px}
|
||||||
|
.status-dot{width:12px;height:12px;border-radius:50%;display:inline-block}
|
||||||
|
.status-green{background:#28a745}
|
||||||
|
.status-red{background:#e55353}
|
||||||
|
footer{margin-top:18px; color:var(--muted); font-size:13px}
|
||||||
|
@media (max-width:720px){.container{grid-template-columns:1fr; padding:18px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Prominent communication status in the top-right -->
|
||||||
|
<div class="comm-status" role="status" aria-live="polite">
|
||||||
|
<span class="label">Comm</span>
|
||||||
|
<span id="comm-dot" class="status-dot status-red" aria-hidden="true"></span>
|
||||||
|
<span id="comm-text" class="meta">Disconnected</span>
|
||||||
|
<span id="comm-last" class="meta" style="margin-left:8px"></span>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="/static/images/ata_logo.png" alt="ATA Logo" />
|
||||||
|
<div>
|
||||||
|
<h1>DSLR Director</h1>
|
||||||
|
<p class="lead">Configure LED animations and communication.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-target="panel-animations">Animations</div>
|
||||||
|
<div class="tab" data-target="panel-comm">Communication</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div id="panel-animations" class="tab-panel active">
|
||||||
|
<form method="post" action="/update">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||||
|
<div style="display:flex;gap:12px;align-items:center">
|
||||||
|
<div class="meta">Current values shown. Leave blank to keep existing.</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="save" type="submit"><i class="fas fa-save"></i> Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<i class="fas fa-bolt" style="width:18px;text-align:center"></i>
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:8px">
|
||||||
|
<span class="label-name">Home Screen</span>
|
||||||
|
</span>
|
||||||
|
<!-- Mode selector and color picker for Home Screen -->
|
||||||
|
<select id="home-mode" name="home_mode" class="mode-select">
|
||||||
|
<option value="animation">Animation</option>
|
||||||
|
<option value="solid">Solid Color</option>
|
||||||
|
</select>
|
||||||
|
<input id="home_animation" type="number" name="home_animation" value="" style="margin-left:8px" />
|
||||||
|
<input id="home_color" type="color" name="home_color" value="#ffffff" title="Pick a color" style="margin-left:8px;border-radius:6px;width:44px;height:36px;padding:4px;border:none;background:transparent" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<i class="fas fa-stopwatch" style="width:18px;text-align:center"></i>
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:8px">
|
||||||
|
<span class="label-name">Countdown</span>
|
||||||
|
</span>
|
||||||
|
<!-- Mode selector and color picker for Countdown -->
|
||||||
|
<select id="countdown-mode" name="countdown_mode" class="mode-select" hidden>
|
||||||
|
<option value="animation">Animation</option>
|
||||||
|
<option value="solid">Solid Color</option>
|
||||||
|
</select>
|
||||||
|
<input id="countdown_animation" type="number" name="countdown_animation" value="" style="margin-left:8px" hidden/>
|
||||||
|
<input id="countdown_color" type="color" name="countdown_color" value="#ffffff" title="Pick a color" hidden style="margin-left:8px;border-radius:6px;width:44px;height:36px;padding:4px;border:none;background:transparent" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<i class="fas fa-tv" style="width:18px;text-align:center"></i>
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:8px">
|
||||||
|
<span class="label-name">Sharing</span>
|
||||||
|
</span>
|
||||||
|
<!-- Mode selector and color picker for Sharing -->
|
||||||
|
<select id="sharing-mode" name="sharing_mode" class="mode-select">
|
||||||
|
<option value="animation">Animation</option>
|
||||||
|
<option value="solid">Solid Color</option>
|
||||||
|
</select>
|
||||||
|
<input id="sharing_animation" type="number" name="sharing_animation" value="" style="margin-left:8px" />
|
||||||
|
<input id="sharing_color" type="color" name="sharing_color" value="#ffffff" title="Pick a color" style="margin-left:8px;border-radius:6px;width:44px;height:36px;padding:4px;border:none;background:transparent" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="note">Tip: Animation IDs are integers. Choose 'Solid Color' and pick a color to send a static color command. Use the Save button to persist changes.</div>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-comm" class="tab-panel">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
|
<div class="field">
|
||||||
|
<label style="align-items:center;gap:8px">
|
||||||
|
<span class="label-name">ID</span>
|
||||||
|
<input type="text" id="comm-id" name="comm_id" value="" style="margin-left:8px;padding:8px;border-radius:8px;background:var(--glass);border:1px solid rgba(255,255,255,0.04);color:inherit" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label style="align-items:center;gap:8px">
|
||||||
|
<span class="label-name">Device Name</span>
|
||||||
|
<input type="text" id="comm-name" name="comm_name" value="" style="margin-left:8px;padding:8px;border-radius:8px;background:var(--glass);border:1px solid rgba(255,255,255,0.04);color:inherit" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label style="align-items:center;gap:8px">
|
||||||
|
<span class="label-name">Filter</span>
|
||||||
|
<input type="text" id="comm-filter" name="comm_filter" value="" style="margin-left:8px;padding:8px;border-radius:8px;background:var(--glass);border:1px solid rgba(255,255,255,0.04);color:inherit" />
|
||||||
|
<button type="button" id="comm-scan" class="save" style="margin-left:8px;padding:8px 10px">Scan</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label style="align-items:center;gap:8px">
|
||||||
|
<span class="label-name">Auto Connect</span>
|
||||||
|
<input type="checkbox" id="comm-auto" name="comm_auto" style="margin-left:8px" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label style="align-items:center;gap:8px">
|
||||||
|
<span class="label-name">Action</span>
|
||||||
|
<button type="button" id="comm-toggle" class="save" style="margin-left:8px">Connect</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="note">Use Connect to start BLE connection from the web UI. Endpoints /ble/connect and /ble/disconnect should be implemented server-side.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- BLE Scan modal (moved here so it's outside header/logo regions) -->
|
||||||
|
<div id="scan-backdrop" class="modal-backdrop" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal" role="document">
|
||||||
|
<h3>Scan Results</h3>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<input id="scan-filter" placeholder="filter" style="flex:1;padding:8px;border-radius:6px;background:var(--glass);border:1px solid rgba(255,255,255,0.04);color:inherit" />
|
||||||
|
<button id="scan-refresh" class="save">Refresh</button>
|
||||||
|
<button id="scan-close" class="save" style="background:transparent;border:1px solid rgba(255,255,255,0.06);color:var(--muted)">Close</button>
|
||||||
|
</div>
|
||||||
|
<div id="device-list" class="device-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// BLE scan modal handlers
|
||||||
|
function showScanModal(){
|
||||||
|
document.getElementById('scan-backdrop').classList.add('modal-show');
|
||||||
|
document.getElementById('scan-filter').value = document.getElementById('comm-filter') ? document.getElementById('comm-filter').value : '';
|
||||||
|
doScan();
|
||||||
|
}
|
||||||
|
function hideScanModal(){ document.getElementById('scan-backdrop').classList.remove('modal-show'); }
|
||||||
|
|
||||||
|
async function doScan(){
|
||||||
|
const listEl = document.getElementById('device-list');
|
||||||
|
listEl.innerHTML = '<div class="note">Scanning...</div>';
|
||||||
|
const prefix = document.getElementById('scan-filter').value || '';
|
||||||
|
try{
|
||||||
|
const resp = await fetch('/ble/scan?prefix=' + encodeURIComponent(prefix));
|
||||||
|
if(!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
const devices = await resp.json();
|
||||||
|
if(!Array.isArray(devices) || devices.length === 0){ listEl.innerHTML = '<div class="note">No devices found</div>'; return; }
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
devices.forEach(d => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'device-item';
|
||||||
|
div.textContent = (d.name || '<unknown>') + ' — ' + (d.address || d.id || '');
|
||||||
|
div.addEventListener('click', ()=>{
|
||||||
|
if(document.getElementById('comm-id')) document.getElementById('comm-id').value = d.address || d.id || '';
|
||||||
|
if(document.getElementById('comm-name')) document.getElementById('comm-name').value = d.name || '';
|
||||||
|
hideScanModal();
|
||||||
|
});
|
||||||
|
listEl.appendChild(div);
|
||||||
|
});
|
||||||
|
}catch(err){ listEl.innerHTML = '<div class="note">Scan failed: '+ (err.message||err) +'</div>'; }
|
||||||
|
}
|
||||||
|
document.getElementById('comm-scan').addEventListener('click', showScanModal);
|
||||||
|
document.getElementById('scan-close').addEventListener('click', hideScanModal);
|
||||||
|
document.getElementById('scan-refresh').addEventListener('click', doScan);
|
||||||
|
|
||||||
|
// Poll /ble/status and update the Comm UI
|
||||||
|
async function fetchBleStatus(){
|
||||||
|
try{
|
||||||
|
const resp = await fetch('/ble/status');
|
||||||
|
if(!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
const data = await resp.json();
|
||||||
|
const dot = document.getElementById('comm-dot');
|
||||||
|
const text = document.getElementById('comm-text');
|
||||||
|
const last = document.getElementById('comm-last');
|
||||||
|
if(data.connected){
|
||||||
|
dot.classList.remove('status-red'); dot.classList.add('status-green');
|
||||||
|
text.textContent = 'Connected';
|
||||||
|
} else {
|
||||||
|
dot.classList.remove('status-green'); dot.classList.add('status-red');
|
||||||
|
text.textContent = 'Disconnected';
|
||||||
|
}
|
||||||
|
if(data.last_connected_ts){
|
||||||
|
// last_connected_ts expected in ISO or unix ms; try to parse
|
||||||
|
let d = new Date(data.last_connected_ts);
|
||||||
|
if(isNaN(d)){
|
||||||
|
// maybe millis
|
||||||
|
d = new Date(Number(data.last_connected_ts));
|
||||||
|
}
|
||||||
|
if(!isNaN(d)) last.textContent = 'Last: ' + d.toLocaleString();
|
||||||
|
else last.textContent = '';
|
||||||
|
} else {
|
||||||
|
last.textContent = '';
|
||||||
|
}
|
||||||
|
}catch(err){
|
||||||
|
// network error or endpoint missing
|
||||||
|
const dot = document.getElementById('comm-dot');
|
||||||
|
const text = document.getElementById('comm-text');
|
||||||
|
const last = document.getElementById('comm-last');
|
||||||
|
if(dot){ dot.classList.remove('status-green'); dot.classList.add('status-red'); }
|
||||||
|
if(text) text.textContent = 'Unknown';
|
||||||
|
if(last) last.textContent = '';
|
||||||
|
console.debug('ble status fetch failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial fetch and interval
|
||||||
|
fetchBleStatus();
|
||||||
|
setInterval(fetchBleStatus, 5000);
|
||||||
|
|
||||||
|
// Tabs behavior
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', e => {
|
||||||
|
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||||||
|
t.classList.add('active');
|
||||||
|
const target = t.getAttribute('data-target');
|
||||||
|
const panel = document.getElementById(target);
|
||||||
|
if(panel) panel.classList.add('active');
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Connect/Disconnect button
|
||||||
|
document.getElementById('comm-toggle').addEventListener('click', async function(){
|
||||||
|
const btn = this;
|
||||||
|
// Decide action by the button label: if it says 'Connect' -> POST /ble/connect, otherwise POST /ble/disconnect.
|
||||||
|
try{
|
||||||
|
const label = (btn.textContent || '').trim().toLowerCase();
|
||||||
|
const isConnectAction = label.startsWith('connect');
|
||||||
|
const url = isConnectAction ? '/ble/connect' : '/ble/disconnect';
|
||||||
|
const payload = { id: document.getElementById('comm-id').value, name: document.getElementById('comm-name').value, filter: document.getElementById('comm-filter').value, auto: document.getElementById('comm-auto').checked, action: isConnectAction ? 'connect' : 'disconnect' };
|
||||||
|
console.debug('comm-toggle: sending', url, payload);
|
||||||
|
const resp = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
|
||||||
|
if(!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
const data = await resp.json().catch(()=>({}));
|
||||||
|
// optimistic update
|
||||||
|
const textEl = document.getElementById('comm-text');
|
||||||
|
if(isConnectAction){
|
||||||
|
if(textEl) textEl.textContent = 'Connected';
|
||||||
|
btn.textContent = 'Disconnect';
|
||||||
|
document.getElementById('comm-dot').classList.remove('status-red');
|
||||||
|
document.getElementById('comm-dot').classList.add('status-green');
|
||||||
|
} else {
|
||||||
|
if(textEl) textEl.textContent = 'Disconnected';
|
||||||
|
btn.textContent = 'Connect';
|
||||||
|
document.getElementById('comm-dot').classList.remove('status-green');
|
||||||
|
document.getElementById('comm-dot').classList.add('status-red');
|
||||||
|
}
|
||||||
|
}catch(err){
|
||||||
|
alert('Connect action failed: ' + err.message + '\nMake sure /ble/connect and /ble/disconnect are implemented.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch initial config for animation controls and communication controls
|
||||||
|
async function loadInitialConfig(){
|
||||||
|
// animations
|
||||||
|
try{
|
||||||
|
const resp = await fetch('/config-animation');
|
||||||
|
if(resp.ok){
|
||||||
|
const cfg = await resp.json();
|
||||||
|
if(cfg.home_mode !== undefined && document.getElementById('home-mode')) document.getElementById('home-mode').value = cfg.home_mode;
|
||||||
|
if(cfg.home_anim !== undefined && document.getElementById('home_animation')) document.getElementById('home_animation').value = cfg.home_anim;
|
||||||
|
if(cfg.home_color !== undefined && document.getElementById('home_color')) document.getElementById('home_color').value = cfg.home_color;
|
||||||
|
|
||||||
|
if(cfg.countdown_mode !== undefined && document.getElementById('countdown-mode')) document.getElementById('countdown-mode').value = cfg.countdown_mode;
|
||||||
|
if(cfg.countdown_anim !== undefined && document.getElementById('countdown_animation')) document.getElementById('countdown_animation').value = cfg.countdown_anim;
|
||||||
|
if(cfg.countdown_color !== undefined && document.getElementById('countdown_color')) document.getElementById('countdown_color').value = cfg.countdown_color;
|
||||||
|
|
||||||
|
if(cfg.sharing_mode !== undefined && document.getElementById('sharing-mode')) document.getElementById('sharing-mode').value = cfg.sharing_mode;
|
||||||
|
if(cfg.sharing_anim !== undefined && document.getElementById('sharing_animation')) document.getElementById('sharing_animation').value = cfg.sharing_anim;
|
||||||
|
if(cfg.sharing_color !== undefined && document.getElementById('sharing_color')) document.getElementById('sharing_color').value = cfg.sharing_color;
|
||||||
|
}
|
||||||
|
}catch(err){ console.debug('loadInitialConfig animations failed', err); }
|
||||||
|
|
||||||
|
// communication
|
||||||
|
try{
|
||||||
|
const resp2 = await fetch('/comm-status');
|
||||||
|
if(resp2.ok){
|
||||||
|
const c = await resp2.json();
|
||||||
|
if(c.id !== undefined && document.getElementById('comm-id')) document.getElementById('comm-id').value = c.id;
|
||||||
|
if(c.name !== undefined && document.getElementById('comm-name')) document.getElementById('comm-name').value = c.name;
|
||||||
|
if(c.filter !== undefined && document.getElementById('comm-filter')) document.getElementById('comm-filter').value = c.filter;
|
||||||
|
if(c.auto !== undefined && document.getElementById('comm-auto')) document.getElementById('comm-auto').checked = !!c.auto;
|
||||||
|
// update connection UI
|
||||||
|
const textEl = document.getElementById('comm-text');
|
||||||
|
const dot = document.getElementById('comm-dot');
|
||||||
|
const btn = document.getElementById('comm-toggle');
|
||||||
|
if(c.connected){ if(textEl) textEl.textContent='Connected'; if(dot){ dot.classList.remove('status-red'); dot.classList.add('status-green'); } if(btn) btn.textContent='Disconnect'; }
|
||||||
|
else { if(textEl) textEl.textContent='Disconnected'; if(dot){ dot.classList.remove('status-green'); dot.classList.add('status-red'); } if(btn) btn.textContent='Connect'; }
|
||||||
|
if(c.last_connected_ts){ const d = new Date(c.last_connected_ts); if(!isNaN(d)) document.getElementById('comm-last').textContent = 'Last: ' + d.toLocaleString(); }
|
||||||
|
}
|
||||||
|
}catch(err){ console.debug('loadInitialConfig comm failed', err); }
|
||||||
|
|
||||||
|
console.debug('Initial config loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// load initial config once DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', loadInitialConfig);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
test_dispatch.py
Normal file
6
test_dispatch.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from src import app as application
|
||||||
|
|
||||||
|
client = application.app.test_client()
|
||||||
|
resp = client.get('/dslrbooth?event_type=session_start¶m1=PrintAndGIF')
|
||||||
|
print('Status', resp.status_code)
|
||||||
|
print('Data', resp.get_data(as_text=True))
|
||||||
9
test_health.py
Normal file
9
test_health.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Testing /health endpoint...")
|
||||||
|
response = requests.get("http://127.0.0.1:8090/health", timeout=5)
|
||||||
|
print(f"Status Code: {response.status_code}")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
26
test_requests.py
Normal file
26
test_requests.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Give the server a moment
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Testing server with GET request...")
|
||||||
|
response = requests.get("http://127.0.0.1:8090/?event_type=session_start¶m1=PrintAndGIF", timeout=10)
|
||||||
|
print(f"Status Code: {response.status_code}")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
|
||||||
|
print("\nTesting countdown request...")
|
||||||
|
response2 = requests.get("http://127.0.0.1:8090/?event_type=countdown¶m1=30", timeout=10)
|
||||||
|
print(f"Status Code: {response2.status_code}")
|
||||||
|
print(f"Response: {response2.text}")
|
||||||
|
|
||||||
|
print("\nTesting file_download request...")
|
||||||
|
response3 = requests.get("http://127.0.0.1:8090/?event_type=file_download¶m1=20250926_222756_010.jpg", timeout=10)
|
||||||
|
print(f"Status Code: {response3.status_code}")
|
||||||
|
print(f"Response: {response3.text}")
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
print(f"Connection error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
23
test_server.py
Normal file
23
test_server.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Give the server a moment to start if needed
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Test the server with a GET request
|
||||||
|
try:
|
||||||
|
print("Testing server with GET request...")
|
||||||
|
response = requests.get("http://127.0.0.1:8090/?event_type=session_start¶m1=PrintAndGIF", timeout=5)
|
||||||
|
print(f"Status Code: {response.status_code}")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
|
||||||
|
# Test another request
|
||||||
|
print("\nTesting another request...")
|
||||||
|
response2 = requests.get("http://127.0.0.1:8090/?event_type=countdown¶m1=30", timeout=5)
|
||||||
|
print(f"Status Code: {response2.status_code}")
|
||||||
|
print(f"Response: {response2.text}")
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print("Could not connect to the server. Is it running on port 8090?")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
6
test_servers.py
Normal file
6
test_servers.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from src import app as application
|
||||||
|
|
||||||
|
client = application.app.test_client()
|
||||||
|
print('GET / ->', client.get('/').status_code)
|
||||||
|
print('GET /api ->', client.get('/api?event_type=session_start¶m1=PrintAndGIF').get_data(as_text=True))
|
||||||
|
print('GET /dslrbooth ->', client.get('/dslrbooth?event_type=countdown¶m1=3').get_data(as_text=True))
|
||||||
Loading…
x
Reference in New Issue
Block a user