This commit is contained in:
admin 2025-09-29 00:46:38 -07:00
commit 9c81a49f8f
61 changed files with 55323 additions and 0 deletions

48
bootstrap.ps1 Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

14
data/comm.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

4666
dslrbooth_triggers.jsonl Normal file

File diff suppressed because it is too large Load Diff

11176
dslrbooth_triggers.log Normal file

File diff suppressed because it is too large Load Diff

12
environment.yml Normal file
View 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
View File

@ -0,0 +1 @@
bleak

28
scripts/README_BACKUP.md Normal file
View 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
View 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
View 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)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1310
src/app.py Normal file

File diff suppressed because it is too large Load Diff

1521
src/gui.py Normal file

File diff suppressed because it is too large Load Diff

20
src/test_blecomm.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

19
static/fontawesome/css/regular.css vendored Normal file
View 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; }

View 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
View 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
View 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
View 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); }

File diff suppressed because one or more lines are too long

26
static/fontawesome/css/v4-font-face.css vendored Normal file
View 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; }

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

22
static/fontawesome/css/v5-font-face.css vendored Normal file
View 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"); }

View 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")}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

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
View 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

File diff suppressed because it is too large Load Diff

497
temp/gui_psg.py Normal file
View 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
View 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
View File

@ -0,0 +1,6 @@
from src import app as application
client = application.app.test_client()
resp = client.get('/dslrbooth?event_type=session_start&param1=PrintAndGIF')
print('Status', resp.status_code)
print('Data', resp.get_data(as_text=True))

9
test_health.py Normal file
View 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
View 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&param1=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&param1=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&param1=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
View 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&param1=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&param1=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
View 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&param1=PrintAndGIF').get_data(as_text=True))
print('GET /dslrbooth ->', client.get('/dslrbooth?event_type=countdown&param1=3').get_data(as_text=True))