Update docs and screenshots
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s

This commit is contained in:
2026-07-25 10:26:29 +02:00
parent d6cda77aaf
commit 1f110866f5
170 changed files with 24657 additions and 3 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Core application services."""
+60
View File
@@ -0,0 +1,60 @@
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="TUXDMX_", env_file=".env", extra="ignore")
app_name: str = "TuxDMX"
env: str = "development"
secret_key: str = "development-secret-change-me"
database_url: str = "sqlite+aiosqlite:///./data/tuxdmx.db"
simulator_enabled: bool = True
allow_local_docs: bool = True
log_level: str = "INFO"
target_fps: int = Field(default=30, ge=1, le=44)
ola_universe: int = Field(default=1, ge=1, le=63999)
ola_output_port: str | None = None
ola_send_timeout_ms: int = Field(default=1000, ge=100, le=10000)
system_control_enabled: bool = True
system_service_name: str = "tuxdmx.service"
system_control_helper: str = "/opt/tuxdmx/scripts/control-system.sh"
frontend_dist: Path = Path("frontend/dist")
data_dir: Path = Path("data")
backup_dir: Path = Path("data/backups")
fixture_cache_dir: Path = Path("data/fixtures")
diagnostics_dir: Path = Path("data/diagnostics")
uploads_dir: Path = Path("data/uploads")
log_dir: Path = Path("data")
def resolve_path(path: Path) -> Path:
return path.expanduser().resolve(strict=False)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
settings = Settings()
settings.frontend_dist = resolve_path(settings.frontend_dist)
settings.data_dir = resolve_path(settings.data_dir)
settings.backup_dir = resolve_path(settings.backup_dir)
settings.fixture_cache_dir = resolve_path(settings.fixture_cache_dir)
settings.diagnostics_dir = resolve_path(settings.diagnostics_dir)
settings.uploads_dir = resolve_path(settings.uploads_dir)
settings.log_dir = resolve_path(settings.log_dir)
settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.backup_dir.mkdir(parents=True, exist_ok=True)
settings.fixture_cache_dir.mkdir(parents=True, exist_ok=True)
settings.diagnostics_dir.mkdir(parents=True, exist_ok=True)
settings.uploads_dir.mkdir(parents=True, exist_ok=True)
settings.log_dir.mkdir(parents=True, exist_ok=True)
return settings
def get_alembic_database_url(database_url: str) -> str:
if database_url.startswith("sqlite+aiosqlite:///"):
return database_url.replace("sqlite+aiosqlite:///", "sqlite:///", 1)
return database_url
+45
View File
@@ -0,0 +1,45 @@
from collections.abc import AsyncIterator
from sqlalchemy import event, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import get_settings
from app.models.base import Base
settings = get_settings()
engine = create_async_engine(settings.database_url, future=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
@event.listens_for(engine.sync_engine, "connect")
def _enable_sqlite_pragmas(dbapi_connection, _connection_record) -> None: # type: ignore[no-untyped-def]
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.execute("PRAGMA foreign_keys=ON;")
cursor.close()
async def init_database() -> None:
async with engine.begin() as connection:
await connection.execute(text("PRAGMA journal_mode=WAL;"))
await connection.run_sync(Base.metadata.create_all)
await _ensure_compatible_schema(connection)
async def get_db_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
async def _ensure_compatible_schema(connection) -> None: # type: ignore[no-untyped-def]
def sync_ensure(sync_connection) -> None: # type: ignore[no-untyped-def]
scene_columns = {
row[1]
for row in sync_connection.exec_driver_sql("PRAGMA table_info('scenes')").fetchall()
}
if "targets" not in scene_columns:
sync_connection.exec_driver_sql(
"ALTER TABLE scenes ADD COLUMN targets JSON NOT NULL DEFAULT '[]'"
)
await connection.run_sync(sync_ensure)
+62
View File
@@ -0,0 +1,62 @@
from app.auth.service import AuthService
from app.backup.service import BackupService
from app.bpm.service import BpmService
from app.core.config import get_settings
from app.dmx.backends import OlaDmxBackend, SimulatorDmxBackend
from app.dmx.engine import DmxEngine
from app.dmx.output_service import DmxOutputService
from app.effects.service import EffectService
from app.fixtures.service import FixtureService
from app.homeassistant.service import HomeAssistantService
from app.live.service import LiveDeskService
from app.midi.service import MidiService
from app.mixitup.service import TriggerService
from app.patch.service import PatchService
from app.scenes.service import SceneService
from app.system.service import SystemControlService
from app.telemetry.service import TelemetryService
class ApplicationState:
def __init__(self) -> None:
settings = get_settings()
self.telemetry = TelemetryService()
backend = (
SimulatorDmxBackend(universe=settings.ola_universe)
if settings.simulator_enabled
else OlaDmxBackend(
universe=settings.ola_universe,
output_port=settings.ola_output_port,
send_timeout_s=settings.ola_send_timeout_ms / 1000,
)
)
self.engine = DmxEngine(self.telemetry, backend=backend)
self.auth = AuthService()
self.fixtures = FixtureService()
self.patch = PatchService()
self.scenes = SceneService(self.engine)
self.bpm = BpmService()
self.effects = EffectService(self.engine, self.bpm)
self.dmx = DmxOutputService(self.engine)
self.home_assistant = HomeAssistantService(self.engine)
self.live = LiveDeskService(self.engine, home_assistant=self.home_assistant)
self.triggers = TriggerService(self.engine, self.effects, self.scenes)
self.midi = MidiService(self.engine, self.scenes, self.effects)
self.backups = BackupService(settings.data_dir, settings.backup_dir)
self.system = SystemControlService()
async def startup(self) -> None:
await self.dmx.startup()
await self.home_assistant.startup()
await self.midi.startup()
await self.bpm.startup()
await self.engine.start()
async def shutdown(self) -> None:
await self.effects.shutdown()
await self.home_assistant.shutdown()
await self.bpm.shutdown()
await self.engine.stop()
app_state = ApplicationState()
+32
View File
@@ -0,0 +1,32 @@
import json
import logging
from datetime import UTC, datetime
from app.core.config import get_settings
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
return json.dumps(payload, ensure_ascii=True)
def configure_logging() -> None:
settings = get_settings()
root = logging.getLogger()
if root.handlers:
return
root.setLevel(settings.log_level.upper())
console = logging.StreamHandler()
console.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
root.addHandler(console)
json_handler = logging.FileHandler(settings.log_dir / "tuxdmx.json.log", encoding="utf-8")
json_handler.setFormatter(JsonFormatter())
root.addHandler(json_handler)