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