diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bb21dc3 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +TUXDMX_ENV=development +TUXDMX_APP_NAME=TuxDMX +TUXDMX_SECRET_KEY=change-me-before-production +TUXDMX_DATABASE_URL=sqlite+aiosqlite:///./data/tuxdmx.db +TUXDMX_SIMULATOR_ENABLED=true +TUXDMX_OLA_UNIVERSE=1 +TUXDMX_OLA_OUTPUT_PORT= +TUXDMX_OLA_SEND_TIMEOUT_MS=1000 +TUXDMX_DATA_DIR=./data +TUXDMX_BACKUP_DIR=./data/backups +TUXDMX_FIXTURE_CACHE_DIR=./data/fixtures +TUXDMX_DIAGNOSTICS_DIR=./data/diagnostics +TUXDMX_UPLOADS_DIR=./data/uploads +TUXDMX_LOG_DIR=./data +TUXDMX_FRONTEND_DIST=frontend/dist +TUXDMX_LOG_LEVEL=INFO +TUXDMX_ALLOW_LOCAL_DOCS=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..278d892 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.lock + - name: Pip check + run: pip check + - name: Import greenlet + run: python -c "import greenlet; print(greenlet.__version__)" + - name: Ruff + run: ruff check backend + - name: Mypy + run: mypy backend/app + - name: Pytest + run: pytest + + shell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install ShellCheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: Bash syntax check + run: | + bash -n scripts/install-pi.sh + bash -n scripts/update-pi.sh + bash -n scripts/install-linux.sh + bash -n scripts/update-linux.sh + bash -n scripts/uninstall-linux.sh + bash -n scripts/lib/install-common.sh + - name: ShellCheck + run: | + shellcheck scripts/install-pi.sh + shellcheck scripts/update-pi.sh + shellcheck scripts/install-linux.sh + shellcheck scripts/update-linux.sh + shellcheck scripts/uninstall-linux.sh + shellcheck scripts/lib/install-common.sh + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.28.2 + - uses: actions/setup-node@v4 + with: + node-version: "20.19.2" + cache: "pnpm" + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + - name: Frontend lint + run: pnpm lint + - name: Frontend typecheck + run: pnpm typecheck + - name: Frontend tests + run: pnpm test + - name: Frontend build + run: pnpm build + + arm64-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - name: ARM64 compile check + run: docker run --rm --platform linux/arm64/v8 -v "$PWD":/workspace -w /workspace python:3.11-slim python -m compileall backend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d71165b --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.pnpm-store/ +node_modules/ +frontend/dist/ +data/ +backend/.coverage +backend/htmlcov/ +backend/.pytest_cache/ +*.pyc +*.pyo +*.sqlite3 +*.db +*.log +*.zip +*.tar +*.tar.gz +.env +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..62958b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +TuxDMX +Copyright (c) Thomas / TuxiNet + +All rights reserved. + +This repository and all accompanying source code, documentation, scripts, builds, +and assets are proprietary and confidential unless Thomas / TuxiNet states +otherwise in writing. + +No permission is granted to copy, modify, distribute, sublicense, publish, +sell, or use this material outside the private scope approved by the copyright +holder. + +Third-party components remain subject to their own licenses. See +THIRD_PARTY_NOTICES.md and docs/DEPENDENCIES.md. + diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..fd77741 --- /dev/null +++ b/NOTICE @@ -0,0 +1,2 @@ +All rights reserved - Thomas / TuxiNet + diff --git a/README.md b/README.md index 50f6c4f..a98747c 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,13 @@ pnpm dev - [HARDWARE-VALIDATION.md](HARDWARE-VALIDATION.md) - [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - [SECURITY.md](SECURITY.md) +- [docs/system-manual/README.md](docs/system-manual/README.md) +- [docs/system-manual/MANUAL.md](docs/system-manual/MANUAL.md) - [docs/API-ENDPOINTS.md](docs/API-ENDPOINTS.md) - [docs/MIXITUP.md](docs/MIXITUP.md) - [docs/OFL.md](docs/OFL.md) - [docs/BPM.md](docs/BPM.md) +- [docs/MIDI_BRIDGE.md](docs/MIDI_BRIDGE.md) - [docs/DEPENDENCIES.md](docs/DEPENDENCIES.md) - [docs/IMPLEMENTATION-STATUS.md](docs/IMPLEMENTATION-STATUS.md) @@ -110,6 +113,32 @@ powershell -ExecutionPolicy Bypass -File .\scripts\sync-windows-deploy.ps1 -Pack Standard-stien for pakken er `Desktop\TuxDMXWebUI-update`, og den kan aendres med `-StageRoot `. +## Dokumentation og screenshots + +- Den opdaterede systemmanual ligger i `docs/system-manual/`. +- Screenshot-saettet er regenereret den 25. juli 2026 fra lokal `mock=1` preview og matcher den nuvaerende kompakte navigation med grupperede menuer. +- Saettet indeholder ogsaa `MIDI`-siden, saa hele den aktuelle WebUI er dækket. + +## Git upload via PowerShell + +Hvis du vil stage, committe og pushe direkte til TuxiNet Git fra PowerShell, kan du bruge helper-scriptet: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\push-git-tuxinet.ps1 -InitIfNeeded -SetOrigin -StageAll -Commit -CommitMessage "Update docs and screenshots" -Push +``` + +Standard-remote er: + +```text +https://git.tuxinet.dk/thomas/TuxDMX-WebUI.git +``` + +Hvis du kun vil se status og remote uden at pushe endnu: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\push-git-tuxinet.ps1 -SetOrigin +``` + ## Produktionsnoter - Debian 13 amd64 er nu førsteborger-platform for Linux-installation. diff --git a/TuxDMX-Codex-Komplet-Specifikation.md b/TuxDMX-Codex-Komplet-Specifikation.md new file mode 100644 index 0000000..d47e997 --- /dev/null +++ b/TuxDMX-Codex-Komplet-Specifikation.md @@ -0,0 +1,2295 @@ +# TuxDMX – komplet projektbeskrivelse til Codex + +## 1. Din rolle + +Du er senior softwarearkitekt og full-stack-udvikler. Byg et komplet, driftssikkert og dokumenteret system med navnet **TuxDMX**. + +TuxDMX skal være en headless, browserbaseret DMX-controller til Raspberry Pi. Systemet skal bruge **Open Lighting Architecture (OLA)** som hardware- og protokolbackend, sende DMX gennem et USB-DMX-interface direkte til Raspberry Pi’en og kunne styres fuldt ud gennem et mørkt Web UI. + +Systemet skal integreres med **MixItUp**, så Twitch-events, chatkommandoer og Channel Point-redemptions kan starte scener og effekter gennem HTTP-kald. + +Codex får ikke adgang til den fysiske Raspberry Pi, USB-DMX-interfacet eller lamperne. Derfor skal al hardwareafhængig kode isoleres bag interfaces, og projektet skal indeholde en komplet simulator, mock-backend, automatiske tests, installationsscripts og en manuel hardwarevalidering, som Thomas kan køre på Raspberry Pi’en. + +Der må ikke stå, at fysisk hardware er testet, når det ikke er tilfældet. + +--- + +# 2. Produktmål + +TuxDMX skal give følgende samlede løsning: + +```text +Browser / mobil / tablet + │ + ▼ +TuxDMX Web UI og REST API + │ + ├── Fixture-, patch-, scene- og effektmotor + ├── MixItUp/Twitch-triggerkø + ├── BPM- og beatmotor + ├── Telemetri og logning + └── Simulator + │ + ▼ +OLA / olad + │ + ▼ +USB-DMX-interface + │ + ▼ +DMX-lamper +``` + +Systemet skal kunne køre uden skærm, tastatur og mus på Raspberry Pi’en. + +--- + +# 3. Ikke-forhandlingsbare krav + +1. **100 % browserbetjening** + - Den normale bruger må ikke skulle redigere JSON, YAML, Python, JavaScript eller shellkode. + - Alle funktioner skal kunne konfigureres gennem formularer, knapper, dropdowns, sliders, farvevælgere, XY-felter, drag-and-drop og guider. + - Rå data må kun vises som read-only diagnostik under en avanceret sektion. + +2. **Mørkt design** + - Primær baggrund: `#080b12` + - Primær accent: `#00e5ff` + - Sekundær accent: `#ff3df2` + - God kontrast, responsivt layout og tydelig statusfarvekodning. + - Dansk som standardsprog. + - Arkitekturen skal være klar til engelsk oversættelse. + +3. **Headless Raspberry Pi** + - Målplatform: Raspberry Pi OS Lite 64-bit på Raspberry Pi 4 eller 5. + - Ingen desktop eller tilsluttet skærm må være nødvendig. + - Autostart med systemd. + - Automatisk genstart ved fejl. + - Web UI skal være tilgængeligt efter reboot. + +4. **USB-DMX direkte i Raspberry Pi** + - OLA skal stå for USB-DMX-driver, patch og output. + - TuxDMX må ikke være hårdt bundet til én bestemt adaptermodel. + - Systemet skal opdage og vise kompatible OLA-enheder og porte. + - Der skal være en guided opsætning af universe og outputport. + +5. **Open Fixture Library** + - Fixtures skal kunne findes gennem en søgning direkte i TuxDMX. + - Brugeren vælger manufacturer, fixture og DMX-mode fra UI. + - Fixturedata skal importeres, valideres, normaliseres og gemmes lokalt. + - Importerede fixtures skal kunne bruges offline efter import. + +6. **MixItUp** + - MixItUp skal være en integreret del af systemet. + - Brugeren skal kunne oprette Twitch-eventmapping uden at skrive kode. + - TuxDMX skal generere endpoint, token, HTTP-metode, headers og request-body, så de kan kopieres til MixItUp. + - TuxDMX skal svare hurtigt og lægge effekten i kø, så MixItUp ikke rammer timeout. + +7. **BPM og beat** + - Manuel BPM. + - Tap tempo. + - Automatisk BPM fra lydinput. + - JACK skal understøttes, men systemet skal også have en enklere ALSA/PipeWire-kompatibel capturemulighed. + - Audioanalyse skal kunne bruge aubio eller en tilsvarende letvægtsløsning. + - Effekter skal kunne synkroniseres til beat, halvnote, kvartnote, ottendedel og sekstendedel. + +8. **Telemetri** + - Live status for OLA, USB-DMX, output, frame rate, fejl og seneste DMX-frame. + - Live visning af alle 512 kanalers aktuelle og ønskede værdier. + - Log over scener, effekter, Twitch-events, BPM og hardwarestatus. + - Systemressourcer: CPU, RAM, temperatur, disk, uptime og processstatus. + - Der skal skelnes tydeligt mellem: + - “Frame afleveret til OLA/USB-backend” + - “Fysisk signal verificeret” + - TuxDMX må ikke hævde at have målt det elektriske DMX-signal uden understøttet RX, RDM, loopback eller ekstern sniffer. + +9. **Driftssikkerhed** + - Blackout/Panic skal altid have højeste prioritet. + - Strobe skal have maksimal varighed og cooldown. + - Ved stop eller fejl skal systemet kunne gå til en konfigurerbar sikker scene. + - Ingen Twitch-bruger må kunne holde en farlig eller generende effekt kørende permanent. + +10. **Ingen automatisk MIT-licens** + - Tilføj ikke MIT-licens eller anden permissiv licens. + - Projektet skal som udgangspunkt være privat/proprietært. + - Brug en kort “All rights reserved – Thomas / TuxiNet” notice, medmindre Thomas senere vælger en anden licens. + - Opret `THIRD_PARTY_NOTICES.md` med relevante tredjepartskomponenter og licenser. + - Kopiér ikke kode fra andre projekter uden korrekt licensgrundlag. + +--- + +# 4. Teknologistak + +## Backend + +Brug: + +- Python 3.11 eller nyere +- FastAPI +- Pydantic +- SQLAlchemy 2 +- Alembic +- SQLite som standard +- WebSocket til live opdateringer +- `httpx` til OFL-integration +- `psutil` til systemtelemetri +- Argon2 til password hashing +- Struktureret logging i JSON samt menneskelæsbar log +- AsyncIO hvor det giver mening + +Backend skal være typeannoteret og bestå statisk typekontrol. + +## Frontend + +Brug: + +- React +- TypeScript +- Vite +- Et let komponentlag; undgå et unødvendigt tungt enterprise-framework +- CSS-variabler eller Tailwind til dark theme +- WebSocket til live data +- Drag-and-drop til patch, grupper og chasertrin +- Responsivt design til desktop, tablet og mobil + +Frontend bygges til statiske filer og serveres af backend eller en lille lokal webserver. Node.js skal ikke være nødvendig ved normal drift på Raspberry Pi’en efter build. + +## DMX-backend + +- OLA / `olad` +- TuxDMX skal have et adapterinterface: + - `OlaDmxBackend` + - `SimulatorDmxBackend` + - mulighed for senere `ArtNetBackend` eller `SacnBackend` +- DMX-motoren skal være adskilt logisk fra HTTP-requesthåndtering. +- En dedikeret worker/proces eller workertråd skal eje frame-loopet, så langsomme webrequests ikke påvirker DMX-timing. + +## Installation + +Normal produktion skal installeres native på Raspberry Pi OS. Brug ikke Docker som primær runtime, fordi USB-, realtime-audio- og OLA-adgang ellers bliver mere kompliceret. + +Der må gerne være en valgfri Docker/devcontainer til lokal udvikling og CI, men den må ikke være den eneste installationsmetode. + +--- + +# 5. Arkitektur + +Opdel systemet i tydelige moduler: + +```text +tuxdmx/ +├── backend/ +│ ├── app/ +│ │ ├── api/ +│ │ ├── auth/ +│ │ ├── bpm/ +│ │ ├── core/ +│ │ ├── dmx/ +│ │ ├── effects/ +│ │ ├── fixtures/ +│ │ ├── mixitup/ +│ │ ├── models/ +│ │ ├── scenes/ +│ │ ├── telemetry/ +│ │ └── websocket/ +│ ├── alembic/ +│ └── tests/ +├── frontend/ +│ ├── src/ +│ └── tests/ +├── systemd/ +├── scripts/ +├── docs/ +├── test-data/ +├── pyproject.toml +├── README.md +├── INSTALL-PI.md +├── HARDWARE-VALIDATION.md +├── TROUBLESHOOTING.md +├── SECURITY.md +└── THIRD_PARTY_NOTICES.md +``` + +## Procesmodel + +Anbefalet: + +1. `olad` + - OLA-daemon. + - Ejer USB-DMX-enheden og outputporten. + +2. `tuxdmx` + - FastAPI, database, triggerkø, WebSocket og UI. + +3. `tuxdmx-engine` + - Dedikeret DMX-frame-, scene- og effektmotor. + - Kommunikerer lokalt med backend gennem Unix socket eller en anden enkel lokal IPC-løsning. + - Må ikke eksponeres direkte på LAN. + +4. `tuxdmx-bpm` + - Kan være integreret i engine-processen eller være en separat proces. + - Skal kunne genstartes uden at stoppe DMX-output. + +Hvis en separat procesmodel bliver unødvendigt kompleks, kan backend og engine køre i samme Python-pakke, men DMX-loopet skal stadig være isoleret fra webserverens event loop. + +--- + +# 6. Førstegangsopsætning + +Ved første besøg skal en guide åbne automatisk. + +## Trin 1 – Administrator + +- Opret lokal administrator. +- Brugernavn. +- Stærkt password. +- Vis ikke standardpassword. +- Opret sessionsbaseret login. + +## Trin 2 – System + +- Vis hostname, IP, platform, arkitektur og OS. +- Kontroller om OLA/`olad` kører. +- Kontroller om TuxDMX kan kommunikere med OLA. +- Vis fundne OLA-devices og porte. + +## Trin 3 – DMX + +- Vælg universe. +- Vælg OLA outputdevice og port. +- Testknap: + - “Send kanal 1 til 20 %” + - “Send kanal 1 til 100 %” + - “Sluk kanal 1” +- Test skal have timer og automatisk nulstille kanalen. + +## Trin 4 – Sikkerhed + +- Maksimal strobevarighed. +- Global master-limit, eksempelvis 80 %. +- Safe scene ved stop. +- Blackout-adfærd. +- Twitch-trigger rate limit. + +## Trin 5 – MixItUp + +- Generér integrations-token. +- Vis TuxDMX base-URL. +- Opret en testtrigger. +- Vis en trin-for-trin MixItUp-guide. + +## Trin 6 – Færdig + +- Gem konfiguration. +- Gå til Dashboard. +- Guiden skal kunne åbnes igen senere. + +--- + +# 7. Brugere og adgang + +Roller: + +- **Administrator** + - Alt. +- **Operator** + - Live Desk, scener, effekter, BPM og blackout. + - Må ikke ændre system-, bruger- eller API-sikkerhedsindstillinger. +- **Read only** + - Dashboard og telemetri. + +Krav: + +- Lokalt login. +- Argon2 password hash. +- Session-cookie. +- CSRF-beskyttelse. +- Rate limit på login. +- Auditlog for konfigurationsændringer. +- Mulighed for at deaktivere login på et fuldstændigt isoleret LAN, men kun efter tydelig advarsel. +- API-tokens skal kunne tilbagekaldes og roteres. +- Tokens vises kun fuldt ud ved oprettelse. + +--- + +# 8. Open Fixture Library-integration + +## Officielle endpoints + +Brug som udgangspunkt: + +```text +POST https://open-fixture-library.org/api/v1/get-search-results +``` + +Eksempel på request: + +```json +{ + "searchQuery": "showtec phantom", + "manufacturersQuery": [], + "categoriesQuery": [] +} +``` + +Resultatet er fixture keys som: + +```json +[ + "showtec/phantom-3r-beam", + "showtec/phantom-50-led-spot" +] +``` + +Hent OFL JSON-formatet gennem: + +```text +https://open-fixture-library.org/{manufacturerKey}/{fixtureKey}.ofl +``` + +Brug eventuelt manufacturer-endpoints til metadata: + +```text +GET https://open-fixture-library.org/api/v1/manufacturers +GET https://open-fixture-library.org/api/v1/manufacturers/{manufacturerKey} +``` + +## Vigtigt om OFL-formatet + +OFL-formatet kan ændre sig mellem schema-versioner. Derfor må resten af TuxDMX ikke bruge OFL-JSON direkte som sit interne runtime-format. + +Implementér: + +```text +OFL JSON + │ + ▼ +OflImporter + │ validering og versionskontrol + ▼ +TuxDMX Normalized Fixture Model + │ + ▼ +Database og runtime +``` + +Gem både: + +- Den originale importerede OFL-fil. +- `$schema`. +- Manufacturer key. +- Fixture key. +- OFL URL. +- Importdato. +- Hash. +- Normaliseret intern fixtureversion. +- Eventuelle warnings og unsupported capabilities. + +## Søge-UI + +Fixture-siden skal have: + +- Søgefelt. +- Manufacturer-filter. +- Kategori-filter. +- Resultatkort med: + - Manufacturer. + - Fixture-navn. + - Kategorier. + - Tilgængelige modes. + - Sidst ændret, når data findes. +- “Vis detaljer”. +- “Importér”. +- “Importér og patch”. +- Lokal cacheindikator. +- Offline fallback. + +## Importfunktioner + +Understøt mindst: + +- Manufacturer. +- Fixture name og short name. +- Categories. +- Physical data. +- DMX connector. +- Modes. +- Available channels. +- Default value. +- Highlight value. +- HTP/LTP precedence. +- Fine channels og 16-bit værdier. +- Capability-ranges. +- ColorIntensity. +- Intensity. +- Pan. +- Tilt. +- Shutter og strobe. +- Gobo- og color wheels. +- Prism. +- Focus. +- Zoom. +- Iris. +- Effect speed. +- Maintenance. +- NoFunction. +- Switching channels. +- Matrix/pixels, hvor det kan normaliseres sikkert. +- Ukendte capability-typer skal bevares som “Generic/Unsupported”, ikke kasseres lydløst. + +## Validering + +- Valider JSON. +- Kontroller mode channel count. +- Kontroller at patchen holder sig inden for kanal 1–512. +- Vis warnings før import. +- Blokér kun ved reelle invalide data. +- Tillad brugerdefineret fixture, hvis en lampe ikke findes i OFL. + +## Opdateringer + +- Vis når en importeret fixture har en nyere OFL-version. +- Overskriv aldrig lokal fixture eller patch automatisk. +- Vis diff og kræv brugerens godkendelse. +- Bevar eksisterende scenes referencer. +- Opret automatisk backup før migration. + +--- + +# 9. Internt fixtureformat + +Design et stabilt internt fixtureformat med versionsnummer. + +Eksempel på hovedbegreber: + +```text +FixtureDefinition +├── manufacturer +├── model +├── categories +├── modes[] +├── channels[] +├── wheels[] +├── matrix +├── physical +├── source +└── schema_version +``` + +Et mode skal indeholde en ordnet kanalmap. + +En normaliseret channel skal blandt andet have: + +- Key. +- Display name. +- Kanaltype. +- Default. +- Highlight. +- Resolution. +- Fine channelrelation. +- Precedence: HTP/LTP. +- Crossfade allowed. +- Capabilities med DMX range og semantik. +- Optional pixel key. +- Optional switching dependency. + +Opret en capability-adapter, så UI kan vise relevante kontroller: + +| Capability | UI-kontrol | +|---|---| +| Intensity | Slider | +| ColorIntensity RGB(W/A/UV) | Farvevælger + kanalsliders | +| Pan/Tilt | XY-pad + sliders | +| ShutterStrobe | Dropdown + strobe slider | +| WheelSlot | Dropdown med slotnavn/ikon | +| Focus/Zoom/Iris | Slider | +| EffectSpeed | Slider | +| Maintenance | Beskyttet dropdown med advarsel | +| Generic/Unsupported | Rå DMX-slider med tydelig mærkning | + +Maintenance-kanaler må ikke vises på Live Desk som standard. + +--- + +# 10. Patch og universer + +## Grundkrav + +- Universe 1 skal være fuldt understøttet. +- Datamodel og API skal være klar til flere universer senere. +- En fixtureinstans består af: + - Brugerdefineret navn. + - Fixturedefinition. + - Mode. + - Startadresse. + - Antal kanaler. + - Gruppe(r). + - Position. + - Tags. + - Enabled/disabled. + +## Patch UI + +- Universe-visning fra kanal 1 til 512. +- Vis optagede og frie områder. +- Drag-and-drop. +- Automatisk “find næste ledige adresse”. +- Konfliktkontrol. +- Vis kanalinterval, eksempelvis `1–14`. +- Mulighed for flere identiske fixtures. +- Duplicate fixture. +- Batch patch. +- Readdress. +- Disable uden at slette. +- Gruppér fixtures: + - Front. + - Bag. + - Venstre. + - Højre. + - Moving heads. + - PAR. + - Strobe. + - Egne grupper. + +## OLA-patch + +TuxDMX skal kunne: + +- Læse OLA-status og devices. +- Vælge outputport. +- Gemme den valgte port. +- Kontrollere ved start om porten stadig findes. +- Gå til tydelig degraded mode, hvis USB-enheden mangler. +- Automatisk genforbinde, når USB-DMX kommer tilbage. +- Ikke sende tilfældige kanalværdier ved reconnect. + +--- + +# 11. Live Desk + +Live Desk er den normale betjeningsside. + +## Toplinje + +- Master dimmer. +- Blackout. +- Freeze. +- Release all temporary effects. +- Aktuel scene. +- Aktuel BPM. +- Beatindikator. +- DMX/USB-status. +- Frame rate. +- Twitch-triggerkø. + +## Fixturekort + +Hvert kort skal vise kontroller, der passer til fixturetypen: + +- Dimmer. +- Farvevælger. +- RGB/RGBW/RGBAWUV. +- Pan/tilt XY-pad. +- Pan og tilt numeric/sliders. +- Shutter. +- Strobe. +- Gobo. +- Prism. +- Focus. +- Zoom. +- Iris. +- Effect/macro. +- Reset og maintenance skal kræve ekstra bekræftelse. + +## Gruppekontrol + +- Vælg fixturegruppe. +- Justér alle fixtures samlet. +- Relative pan/tilt. +- Fan/spread. +- Mirror. +- Invert pan/tilt. +- Global farve. +- Global dimmer. + +## Programmer/preview + +- Live output. +- Blind/editor mode. +- Preview i simulator uden at sende til hardware. +- “Take live” med fade. + +--- + +# 12. Scener + +En scene er et statisk eller delvist statisk sæt kanal-/fixtureværdier. + +Funktioner: + +- Opret. +- Redigér. +- Duplikér. +- Slet. +- Aktivér. +- Deaktivér. +- Fade in. +- Fade out. +- Hold time. +- Prioritet. +- Tags. +- Farve/ikon. +- Valgfri BPM-quantize. +- Valgfri master-limit. +- Gem kun berørte parametre, så en scene kan lægges oven på en anden. +- Preview før aktivering. +- Scenegrupper/banks. + +Eksempler: + +- Normal. +- Stream. +- Pause. +- Pink. +- Blue. +- Warm white. +- DJ intro. +- Raid. +- Blackout. +- Safe shutdown. + +Ved aktivering skal UI vise: + +- Starttid. +- Fade status. +- Kilde: manuel, MixItUp, API, schedule eller system. +- Bruger/event. +- Prioritet. +- Hvor længe scenen har kørt. + +--- + +# 13. Chasers og effektmotor + +## Chasers + +- En chaser består af steps. +- Hvert step kan være: + - Scene. + - Delvis fixture-state. + - Effekt. + - Pause. +- Step duration. +- Fade duration. +- Loop count. +- Ping-pong. +- Random order. +- BPM-sync. +- Start/stop/pause/resume. +- Restore previous state. + +## Indbyggede effekter + +Implementér mindst: + +1. Dimmer pulse. +2. Strobe med sikkerhedsbegrænsning. +3. RGB/RGBW color chase. +4. Rainbow. +5. Random color. +6. Alternate group chase. +7. Fade mellem farver. +8. Beat flash. +9. Moving-head circle. +10. Moving-head figure-eight. +11. Pan sweep. +12. Tilt sweep. +13. Fan/spread. +14. Raid-sekvens. +15. Subscriber-sekvens. +16. Follow flash. +17. Channel Point-farvevalg. +18. Blackout. +19. Return-to-base-scene. +20. Audio-reactive intensity. + +## Parametre + +Alle effektparametre skal konfigureres via UI: + +- Fixturegruppe. +- Farver. +- Intensitet. +- Varighed. +- Hastighed. +- BPM division. +- Fade. +- Strobe rate. +- Bevægelsesstørrelse. +- Center. +- Retning. +- Loop. +- Prioritet. +- Cooldown. +- Restore behavior. + +## Tilstand og prioritering + +Implementér en state/effect stack. + +Eksempel: + +```text +Base scene: Stream + │ + ├── Sub-effect, prioritet 40, 8 sekunder + │ + └── Raid-effect, prioritet 60, 15 sekunder + │ + └── Blackout, prioritet 1000 +``` + +Når en midlertidig effekt slutter, skal systemet vende tilbage til den korrekte underliggende state. + +## Merge + +- Intensity-kanaler bruger normalt HTP. +- Movement, color, wheels og andre positionskanaler bruger normalt LTP. +- Brug OFL precedence, når det findes. +- Brug en dokumenteret fallbackmapping, når precedence mangler. +- Blackout skal kunne overstyre alt. +- Freeze skal stoppe ændringer uden at nulstille output. + +--- + +# 14. DMX-frame engine + +## Grundprincip + +- 512 kanaler pr. universe. +- Værdier 0–255. +- Konfigurerbar outputrate, eksempelvis 25, 30 eller 40 frames/s. +- Standard: 30 frames/s, medmindre hardwaretest viser andet. +- Engine beregner target frame, transitions, effects og merge. +- Den seneste komplette frame afleveres til OLA. + +## Målinger + +For hver periode skal engine registrere: + +- Target frame rate. +- Faktisk frame rate. +- Jitter. +- Frame calculation time. +- OLA send duration. +- Success/failure. +- Dropped/skipped frames. +- Queue depth. +- Seneste succesfulde send. +- Seneste fejl. +- Reconnect count. + +## Fejlhåndtering + +- OLA utilgængelig: + - Log fejl. + - Gå i degraded mode. + - Fortsæt state engine. + - Forsøg reconnect med backoff. +- USB mangler: + - Tydelig rød status. + - Ingen falsk “DMX OK”. + - Automatisk reconnect. +- Engine crash: + - systemd restart. + - Safe state ved ny opstart. +- Databasefejl: + - DMX-engine skal så vidt muligt fortsætte med seneste in-memory state. +- UI disconnect: + - Må ikke stoppe lyset. + +--- + +# 15. BPM- og beatmotor + +## Input modes + +1. **Manual** + - Brugeren indtaster BPM. + +2. **Tap tempo** + - Stor tap-knap. + - Tastaturgenvej. + - Beregn median/robust average af seneste taps. + - Reset efter længere pause. + +3. **Audio** + - Vælg audio device/input. + - Understøt mono/stereo. + - Vælg kanal: left, right eller mix. + - Signal meter. + - Silence detection. + - BPM confidence. + +4. **External API** + - Modtag BPM og beat-pulse gennem REST/WebSocket. + - Gør det muligt senere at koble DJ-software, Companion eller andre systemer på. + +5. **Fremtidig adapter** + - Arkitekturen skal gøre MIDI Clock og OSC muligt senere, men det behøver ikke være fuldt implementeret i første release. + +## Audio capture + +Prioritér denne rækkefølge: + +1. JACK, når JACK er aktiveret og tilgængelig. +2. PipeWire/JACK compatibility, når relevant. +3. ALSA direkte capture som fallback. + +Brug aubio eller tilsvarende til: + +- Tempo detection. +- Beat detection. +- Confidence. +- Onsets. + +## Stabilisering + +- Konfigurerbart BPM-range, standard 60–200. +- Half/double-time correction. +- Smoothing. +- Minimum confidence. +- Hold seneste stabile BPM ved korte udfald. +- Markér BPM som “usikker” ved lav confidence. +- Beat phase må ikke hoppe voldsomt ved hver ny analyse. +- Mulighed for manuel “×2”, “÷2” og nudge. + +## Beat event bus + +Udsend interne events: + +```text +beat +bar +half +quarter +eighth +sixteenth +``` + +Effekter skal kunne quantizes til næste beat eller bar. + +## UI + +BPM-siden skal vise: + +- Aktuel BPM. +- Kilde. +- Confidence. +- Signalstyrke. +- Seneste beat. +- Beat-animation. +- Tap-knap. +- Start/stop audioanalyse. +- Audio device. +- Input channel. +- Sensitivity. +- BPM-range. +- Half/double. +- Test click track i simulator. + +## Testdata + +Opret syntetiske click tracks i tests ved eksempelvis: + +- 90 BPM. +- 120 BPM. +- 128 BPM. +- 140 BPM. +- 174 BPM. + +Brug ikke ophavsretligt beskyttet musik i repository. + +--- + +# 16. MixItUp og Twitch + +## Integrationsprincip + +MixItUp bruger Web Request Actions til at kalde TuxDMX. + +TuxDMX skal returnere hurtigt: + +- HTTP `202 Accepted` ved accepteret trigger. +- Triggeren lægges i intern kø. +- Svartid skal normalt være under 200 ms på LAN. +- Langvarige lyseffekter må aldrig holde HTTP-requesten åben. + +## API-token + +- Opret et separat token til MixItUp. +- Bearer token i header. +- Token kan roteres. +- Mulighed for IP allowlist. +- Rate limiting. +- Auditlog. +- Ingen API-token i URL querystring. + +## Generisk triggerendpoint + +```http +POST /api/v1/triggers/{slug} +Authorization: Bearer +Content-Type: application/json +``` + +Eksempel: + +```json +{ + "eventType": "raid", + "username": "$username", + "displayName": "$displayname", + "amount": "$raidviewercount", + "eventId": "$eventid", + "platform": "twitch", + "metadata": {} +} +``` + +TuxDMX må acceptere, at nogle MixItUp-identifiers mangler. + +## Simpelt endpoint + +Der skal også kunne genereres simple endpoints: + +```text +POST /api/v1/scenes/{sceneSlug}/activate +POST /api/v1/effects/{effectSlug}/trigger +POST /api/v1/blackout +POST /api/v1/bpm/tap +``` + +## Mapping-UI + +Siden “MixItUp” skal gøre det muligt at: + +- Oprette integration. +- Navngive integration. +- Generere token. +- Vælge eventtype: + - Follow. + - Subscription. + - Resub. + - Gift sub. + - Bits/Cheers. + - Raid. + - Channel Point Reward. + - Chat command. + - Moderator command. + - Giveaway event. + - Custom. +- Vælge scene eller effekt. +- Vælge varighed. +- Prioritet. +- Cooldown. +- Minimum amount/viewers/bits. +- Tilladte brugerroller. +- Restore behavior. +- Max samtidige triggers. +- Duplicate/idempotency window. +- Aktiv/inaktiv. +- Test-knap. + +## MixItUp-guide i UI + +For hver mapping skal UI generere: + +- HTTP-metode. +- URL. +- Headernavn og token. +- JSON body. +- Hvilke MixItUp special identifiers der foreslås. +- Copy-knapper. +- Testresultat. +- Seneste kald. +- Seneste fejl. + +Brugeren må ikke skulle skrive JavaScript. + +## Eventkø + +Køen skal understøtte: + +- Prioritet. +- Max længde. +- Deduplication med eventId. +- Cooldown per mapping. +- Cooldown per bruger. +- Global cooldown. +- Drop/replace/queue policy. +- “Raid erstatter mindre follow-effekt”. +- “Blackout rydder køen”. +- Live visning af køen. + +## Standardpresets + +Opret standardpresets, der kan redigeres: + +- Follow: kort hvid flash. +- Sub: pink/guld pulse. +- Resub: længere pulse. +- Gift sub: chase afhængigt af antal. +- Bits: intensitet afhængigt af amount. +- Raid: stor sekvens afhængigt af viewer count. +- Channel Points: valgt farve. +- `!party`: rainbow. +- Moderator blackout. +- Moderator normal scene. + +--- + +# 17. REST API + +API skal være versioneret under `/api/v1`. + +Minimum: + +## System + +```text +GET /api/v1/health +GET /api/v1/system/status +GET /api/v1/system/diagnostics +POST /api/v1/system/restart-service +``` + +## OLA/DMX + +```text +GET /api/v1/dmx/status +GET /api/v1/dmx/devices +GET /api/v1/dmx/universes +GET /api/v1/dmx/frame +POST /api/v1/dmx/test-channel +POST /api/v1/dmx/blackout +POST /api/v1/dmx/release-blackout +``` + +## Fixtures + +```text +GET /api/v1/fixtures +POST /api/v1/fixtures/search-ofl +POST /api/v1/fixtures/import-ofl +POST /api/v1/fixtures/import-file +POST /api/v1/fixtures/custom +GET /api/v1/fixtures/{id} +PUT /api/v1/fixtures/{id} +DELETE /api/v1/fixtures/{id} +``` + +## Patch + +```text +GET /api/v1/patch +POST /api/v1/patch +PUT /api/v1/patch/{id} +DELETE /api/v1/patch/{id} +POST /api/v1/patch/validate +``` + +## Scenes + +```text +GET /api/v1/scenes +POST /api/v1/scenes +GET /api/v1/scenes/{id} +PUT /api/v1/scenes/{id} +DELETE /api/v1/scenes/{id} +POST /api/v1/scenes/{slug}/activate +POST /api/v1/scenes/{slug}/release +``` + +## Effects/chasers + +```text +GET /api/v1/effects +POST /api/v1/effects +PUT /api/v1/effects/{id} +DELETE /api/v1/effects/{id} +POST /api/v1/effects/{slug}/trigger +POST /api/v1/effects/{slug}/stop +``` + +## BPM + +```text +GET /api/v1/bpm/status +POST /api/v1/bpm/manual +POST /api/v1/bpm/tap +POST /api/v1/bpm/audio/start +POST /api/v1/bpm/audio/stop +POST /api/v1/bpm/external +GET /api/v1/bpm/devices +``` + +## MixItUp + +```text +GET /api/v1/integrations/mixitup +POST /api/v1/integrations/mixitup +PUT /api/v1/integrations/mixitup/{id} +DELETE /api/v1/integrations/mixitup/{id} +POST /api/v1/integrations/mixitup/{id}/test +POST /api/v1/triggers/{slug} +``` + +## Telemetri + +```text +GET /api/v1/telemetry/live +GET /api/v1/telemetry/history +GET /api/v1/events +GET /api/v1/logs +``` + +## Backup + +```text +POST /api/v1/backups +GET /api/v1/backups +POST /api/v1/backups/{id}/restore +DELETE /api/v1/backups/{id} +``` + +FastAPI OpenAPI-dokumentation må eksistere, men skal som standard kun være tilgængelig for administrator eller på localhost i production. + +--- + +# 18. WebSocket + +Brug WebSocket til: + +- DMX frame/live channel values. +- Telemetri. +- BPM og beat. +- Scene transitions. +- Effektstatus. +- Triggerkø. +- Hardwarestatus. +- Logs. + +Klienten skal reconnecte automatisk med backoff. + +Undgå at sende alle 512 kanaler unødigt ved hver UI-frame. Brug: + +- Deltas, når det er praktisk. +- Begrænset UI-opdateringsrate. +- Snapshot ved connect. +- Binary format er valgfrit; almindelig kompakt JSON er acceptabelt i første release. + +--- + +# 19. Telemetri og diagnostik + +## Dashboard + +Vis: + +- TuxDMX version. +- Uptime. +- OLA connected/disconnected. +- USB-DMX device. +- Universe/port. +- DMX output aktiv. +- Software output status. +- Aktuel scene. +- Aktive effekter. +- BPM. +- Triggerkø. +- CPU. +- RAM. +- CPU-temperatur. +- Disk. +- Netværk. +- Seneste fejl. + +## DMX-monitor + +Vis: + +- 512-kanals grid/heatmap. +- Kanalnummer. +- Current value. +- Target value. +- Source. +- Fixture. +- Parameter. +- Priority. +- Fade status. +- Sidst ændret. +- Mini-graf for valgte kanaler. + +Vis også decoded fixture view, eksempelvis: + +```text +Fixture: Moving Head Left +Dimmer: 255 +Color: Blue +Pan: 32768 / 50 % +Tilt: 22000 / 34 % +Gobo: Stars +Shutter: Open +``` + +## USB/OLA + +Vis: + +- USB vendor/product ID, når tilgængeligt. +- Device path. +- OLA plugin. +- OLA device ID. +- Port. +- RX/TX/RDM capabilities. +- Connected since. +- Reconnect count. +- Seneste OLA-fejl. +- Seneste succesfulde frame. + +## Fysisk verifikation + +UI skal have en forklaring: + +> TuxDMX kan se den frame, som er beregnet og afleveret til OLA. Det beviser ikke alene, at det elektriske DMX-signal når lampen. Fysisk verifikation kræver understøttet DMX-input, RDM, loopback eller ekstern analyzer. + +Hvis adapteren understøtter RX eller RDM, må der senere tilføjes: + +- DMX input monitor. +- RDM discovery. +- Device responses. +- Loopback test. + +## Logning + +Kategorier: + +- SYSTEM. +- AUTH. +- OLA. +- USB. +- DMX. +- FIXTURE. +- PATCH. +- SCENE. +- EFFECT. +- BPM. +- MIXITUP. +- API. +- BACKUP. + +Niveauer: + +- DEBUG. +- INFO. +- WARNING. +- ERROR. +- CRITICAL. + +UI skal kunne: + +- Filtrere. +- Søge. +- Downloade diagnostikbundle. +- Vise seneste 100/1000 linjer. +- Redigere retention. + +Standard retention: + +- Detaljeret telemetri: 7 dage. +- Aggregeret telemetri: 30 dage. +- Eventlog: 90 dage. +- Auditlog: 180 dage. + +Gør værdierne konfigurerbare. + +--- + +# 20. Database + +Brug SQLite med WAL mode. + +Tabeller bør mindst dække: + +- users +- sessions +- api_tokens +- audit_log +- settings +- fixture_sources +- fixture_definitions +- fixture_modes +- fixture_channels +- fixture_capabilities +- fixture_instances +- fixture_groups +- fixture_group_members +- universes +- ola_bindings +- scenes +- scene_layers +- scene_values +- effects +- effect_parameters +- chasers +- chaser_steps +- trigger_mappings +- trigger_events +- trigger_queue +- bpm_settings +- telemetry_samples +- system_events +- backups +- schema_migrations + +Gem ikke telemetri ukontrolleret i fuld opløsning for evigt. Implementér pruning og aggregering. + +--- + +# 21. Backup og restore + +Backup skal indeholde: + +- Database. +- Konfiguration. +- Importerede OFL-filer. +- Custom fixtures. +- Scener. +- Effekter. +- MixItUp mappings. +- Brugere, men aldrig plaintext passwords. +- Version og manifest. +- Checksums. + +Funktioner: + +- Manuel backup. +- Automatisk daglig backup. +- Retention. +- Download gennem UI. +- Upload og restore. +- Pre-restore backup. +- Valider backup før restore. +- Vis hvilke versioner backupen kommer fra. +- Migration ved ældre schema. +- Restore må kræve administratorpassword. + +--- + +# 22. Systemservice og autostart + +Opret systemd units: + +```text +olad.service +tuxdmx.service +tuxdmx-engine.service +tuxdmx-bpm.service +``` + +Brug kun de services, arkitekturen reelt kræver. + +Krav: + +- `After=network-online.target` +- OLA skal være startet før DMX-engine. +- `Restart=on-failure` +- Fornuftig restart delay. +- Dedicated Linux user: `tuxdmx` +- Mindst mulige rettigheder. +- Medlemskab af nødvendige grupper: + - dialout + - plugdev + - audio +- Skrivbare data under `/var/lib/tuxdmx` +- Konfiguration under `/etc/tuxdmx` +- Logs gennem journald samt applikationslog. +- Applikation under `/opt/tuxdmx` +- Ingen drift som root, medmindre en helt konkret hardwarefunktion kræver det. + +## Safe shutdown + +Ved normal service-stop: + +1. Stop nye triggers. +2. Stop midlertidige effekter. +3. Fade til safe scene eller blackout efter indstilling. +4. Send flere sikre frames. +5. Stop DMX-engine. +6. Stop applikation. + +--- + +# 23. Installationsscript + +Opret: + +```text +scripts/install-pi.sh +scripts/update-pi.sh +scripts/uninstall-pi.sh +scripts/pi-smoke-test.sh +scripts/collect-diagnostics.sh +``` + +## `install-pi.sh` + +Skal: + +- Kontrollere OS og arkitektur. +- Kontrollere internet. +- Installere nødvendige apt-pakker. +- Installere eller kontrollere OLA. +- Oprette systembruger og mapper. +- Oprette Python virtualenv. +- Installere låste Python dependencies. +- Kopiere frontend build. +- Installere systemd units. +- Oprette config. +- Generere secret. +- Aktivere services. +- Vise IP og Web UI URL. +- Ikke overskrive eksisterende installation uden backup og bekræftelse. +- Have `--non-interactive` til automatiseret installation. +- Have tydelige fejlbeskeder. + +## `update-pi.sh` + +- Stop nye triggers. +- Backup. +- Download/kopiér ny version. +- Installer dependencies. +- Kør Alembic migration. +- Byg/installer. +- Start services. +- Kør health check. +- Rollback ved fejl. + +## `pi-smoke-test.sh` + +Skal kontrollere: + +- OS/arkitektur. +- OLA package/version. +- `olad` status. +- TuxDMX services. +- API health. +- Database. +- WebSocket. +- OLA forbindelse. +- USB devices. +- OLA device/port. +- Permission groups. +- Audio devices. +- JACK/ALSA status. +- Temperatur/disk. +- Send testframe i simulator. +- Valgfri hardwarekanaltest med eksplicit brugerbekræftelse. +- Returnere non-zero exit code ved fejl. +- Skrive en klar rapport. + +## `collect-diagnostics.sh` + +Skal generere en ZIP/TAR med: + +- Service status. +- Journaluddrag. +- TuxDMX logs. +- OLA status/config. +- `lsusb`. +- Relevant `dmesg`, filtreret. +- OS info. +- Python/package versions. +- Netværksstatus. +- Audio devices. +- Redigeret config uden secrets. +- Database integrity check. +- Ingen API tokens eller password hashes i rapporten. + +--- + +# 24. Simulator og udvikling uden Raspberry Pi + +Dette er obligatorisk, fordi Codex ikke kan teste den fysiske Pi. + +## Simulator backend + +`SimulatorDmxBackend` skal: + +- Emulere universe med 512 kanaler. +- Acceptere frames på samme måde som OLA-backenden. +- Simulere frame latency. +- Simulere fejl: + - USB disconnected. + - OLA unavailable. + - Send timeout. + - Dropped frame. + - Slow backend. +- Vise output i UI. +- Gemme framehistorik i memory. +- Understøtte automatiske assertions. + +## Fixture simulator + +Vis fixturekort visuelt: + +- PAR: farve og lysstyrke. +- Moving head: pan/tilt position, beam color, dimmer, gobo text. +- Strobe: vis sikker animation, ikke aggressiv fuldskærmsblink. +- Matrix: pixelgrid. + +Simulatoren behøver ikke være en fuld 3D-visualizer. + +## Fake OLA + +Implementér et mock interface, ikke en kopi af OLA. + +Tests skal kunne verificere: + +- Frames har korrekt længde. +- Værdier er 0–255. +- Correct universe. +- Reconnect. +- Error handling. +- Frame rate. +- Merge. +- Fade. + +## Recorded OFL responses + +Læg små, lovlige testfixtures i `test-data`: + +- RGB 3 channel. +- RGBW med dimmer og strobe. +- Moving head med 16-bit pan/tilt. +- Fixture med gobo/color wheel. +- Fixture med switching channel. +- Matrix/pixel fixture. +- Invalid fixture. + +Brug snapshots eller reducerede testdata, og dokumentér kilden/licensen. + +--- + +# 25. Tests + +## Backend unit tests + +- OFL parsing og normalisering. +- Schema/version detection. +- 8-bit og 16-bit. +- Fine channels. +- Capability ranges. +- Switching channels. +- Patch conflict. +- Address boundary 512. +- HTP. +- LTP. +- Scene merge. +- Fade interpolation. +- Effect stack. +- Restore previous state. +- Blackout priority. +- Strobe limits. +- Trigger cooldown. +- Idempotency. +- Token auth. +- Telemetry aggregation. +- Backup/restore. +- Database migrations. +- BPM smoothing. +- Half/double BPM. +- Synthetic click tracks. + +## Integration tests + +- FastAPI + SQLite. +- WebSocket. +- Mock engine. +- Fake OLA backend. +- OFL HTTP mock. +- MixItUp trigger request. +- Queue processing. +- Service degraded mode. + +## Frontend tests + +- Login. +- First-run wizard. +- Fixture search. +- Import. +- Patch. +- Scene creation. +- Effect creation. +- MixItUp mapping. +- Blackout. +- Telemetry. +- Responsive layout. +- Accessibility basics. + +Brug Playwright eller tilsvarende til centrale flows. + +## CI + +CI skal køre: + +- Formatting. +- Lint. +- Type check. +- Unit tests. +- Integration tests. +- Frontend tests. +- Production build. +- Dependency audit. +- Arm64 build/check gennem QEMU eller cross-build, hvor det er praktisk. + +CI må ikke markere fysisk DMX-hardware som testet. + +--- + +# 26. Sikkerhedsregler for lys + +## Strobe + +Standard: + +- Max varighed: 3 sekunder. +- Absolut max: 10 sekunder. +- Cooldown: 30 sekunder. +- Twitch må ikke ændre de globale maxgrænser. +- UI skal advare om fotosensitivitet. +- Administrator kan sænke grænserne. +- Strobe skal stoppe ved blackout, enginefejl eller trigger cancellation. + +## Master limit + +- Global intensitetsgrænse. +- Twitch-specifik intensitetsgrænse. +- Testmode-grænse. +- Per fixture/group limit. + +## Blackout + +- Fast knap i alle relevante UI-visninger. +- Tastaturgenvej. +- API endpoint. +- Højeste prioritet. +- Ryd eller pause triggerkø efter konfiguration. +- Kræv bekræftelse for release, hvis aktiveret som emergency blackout. + +## Maintenance/reset + +- Fixture reset, lamp on/off og maintenance-funktioner skal være beskyttet. +- Ikke tilgængelig for Twitch. +- Kræv administrator eller særskilt confirmation. + +--- + +# 27. UI-sider + +## 1. Login + +- Mørkt. +- TuxDMX logo/tekst. +- Ingen standardcredentials. + +## 2. Dashboard + +- Systemstatus. +- DMX. +- Scene. +- BPM. +- Twitch. +- Telemetri. +- Quick actions. + +## 3. Live Desk + +- Master. +- Blackout. +- Fixture- og gruppekontrol. +- Scene/effect launchpad. + +## 4. Fixtures + +- Lokale fixtures. +- OFL-søgning. +- Import. +- Custom fixture editor. +- Update status. + +## 5. Patch + +- Universe 1–512. +- Fixture placement. +- Conflict detection. +- OLA output. + +## 6. Groups + +- Fixturegrupper. +- Drag-and-drop. +- Tags. + +## 7. Scenes + +- Liste. +- Editor. +- Preview. +- Launch. + +## 8. Effects + +- Presets. +- Effect builder. +- Chaser timeline. +- BPM sync. + +## 9. BPM + +- Manual. +- Tap. +- Audio. +- Confidence. +- Input devices. + +## 10. MixItUp + +- Tokens. +- Mappings. +- Generator. +- Test. +- Eventlog. +- Queue. + +## 11. DMX Monitor + +- 512 grid. +- Decoded fixtures. +- Sources og priorities. + +## 12. Telemetry + +- Historik. +- Grafer. +- OLA/USB. +- System. + +## 13. Logs + +- Filter. +- Search. +- Download diagnostics. + +## 14. Backup + +- Create. +- Download. +- Restore. +- Retention. + +## 15. Settings + +- System. +- DMX. +- Safety. +- BPM. +- Telemetry. +- Language. +- Users. +- API. +- Update. + +--- + +# 28. Designkrav + +- Dark-only i første release. +- Undgå hvid flash ved sideindlæsning. +- Store touchvenlige knapper. +- Blackout skal være tydelig, men ikke kunne aktiveres ved et let fejltap uden valgfri bekræftelse. +- Brug status: + - Grøn: normal. + - Gul: degraded/warning. + - Rød: fejl/blackout. + - Blå/cyan: aktiv/live. + - Pink: Twitch/event. +- Ingen rå JSON-editor i normal UI. +- Tooltips med fixturecapability. +- Tastaturgenveje må kunne slås fra. +- Browser refresh må ikke ændre DMX-state. +- UI skal kunne miste forbindelsen uden at lyset stopper. + +--- + +# 29. Performancekrav + +Mål for Raspberry Pi 4/5: + +- Web UI reagerer normalt under 250 ms på LAN. +- Triggerendpoint svarer normalt under 200 ms. +- DMX-engine holder den valgte frame rate med begrænset jitter. +- WebSocket telemetri må ikke blokere DMX output. +- Database pruning skal køre uden mærkbar påvirkning. +- Fixture søgning skal bruge cache og debounce. +- Ingen memory leak ved 24 timers drift. +- Systemet skal kunne køre mindst 72 timer i simulator soak test. + +--- + +# 30. Fejltilstande, der skal håndteres + +- Ingen internet ved boot. +- OFL utilgængelig. +- USB-DMX ikke tilsluttet. +- USB-DMX trækkes ud under drift. +- OLA stopper. +- OLA starter langsomt. +- Audio device mangler. +- Audio device ændrer navn. +- JACK utilgængelig. +- Dårligt eller stille audiosignal. +- Database locked. +- Disk næsten fuld. +- Pi overtemperatur. +- Netværk forsvinder. +- Browser disconnect. +- Dublet Twitch-event. +- Trigger spam. +- Ugyldig OFL fixture. +- Fixture update ændrer mode. +- Scene refererer til slettet fixture. +- Backup fra ældre schema. +- Strømafbrydelse under write. +- Service restart midt i en effekt. + +Systemet skal vise forklarende fejl og foreslå næste skridt. + +--- + +# 31. Dokumentation + +## `README.md` + +- Hvad TuxDMX er. +- Arkitektur. +- Screenshots eller mock screenshots. +- Hurtig simulatorstart. +- Links til øvrig dokumentation. +- Ingen påstand om hardwaretest. + +## `INSTALL-PI.md` + +- Hardwarekrav. +- Raspberry Pi OS Lite 64-bit. +- USB-DMX. +- USB audio input. +- Installation. +- OLA. +- Firewall. +- Første login. +- Autostart. +- Update. +- Uninstall. + +## `HARDWARE-VALIDATION.md` + +En præcis trin-for-trin plan: + +1. Kør diagnostics. +2. Kontrollér OLA. +3. Tilslut USB-DMX. +4. Identificér device. +5. Patch port. +6. Tilslut én simpel lampe. +7. Sæt lampens DMX address. +8. Test dimmer. +9. Test RGB. +10. Test blackout. +11. Test reconnect. +12. Test scene. +13. Test MixItUp. +14. Test BPM. +15. Test reboot/autostart. +16. Kør 2 timers soak test. +17. Gem diagnostic bundle. + +Hvert trin skal have forventet resultat og fejlsøgning. + +## `TROUBLESHOOTING.md` + +- OLA kan ikke se USB. +- Permission denied. +- `/dev/ttyUSB*`. +- FTDI/Open DMX vs Pro device. +- Forkert plugin. +- Ingen DMX. +- Flimren. +- Service restart. +- Web UI virker ikke. +- MixItUp timeout. +- BPM finder forkert tempo. +- Audio xruns. +- Høj CPU. +- Databasefejl. + +## `MIXITUP.md` + +- Opret Web Request Action. +- Method. +- URL. +- Headers. +- JSON. +- Test. +- Special identifiers. +- Fejlsøgning. +- Sikkerhed. + +## `OFL.md` + +- Search. +- Import. +- Cache. +- Schema. +- Unsupported capabilities. +- Updating fixtures. + +## `SECURITY.md` + +- LAN deployment. +- Tokens. +- Passwords. +- Reverse proxy. +- HTTPS. +- Secrets. +- Backup. +- Reporting. + +--- + +# 32. Første releases omfang + +## Release 1.0 skal indeholde + +- Raspberry Pi headless installation. +- OLA backend. +- Simulator backend. +- Ét universe. +- USB-DMX output gennem OLA. +- OFL search/import. +- Custom fixture editor. +- Patch. +- Groups. +- Live Desk. +- Scenes. +- Chasers. +- De beskrevne grundeffekter. +- HTP/LTP merge. +- Blackout. +- Strobe safety. +- Manual BPM. +- Tap BPM. +- Audio BPM. +- MixItUp. +- REST API. +- WebSocket. +- Telemetry. +- Logs. +- Backup/restore. +- Users/roles. +- Autostart. +- Diagnostics. +- Tests. +- Dokumentation. + +## Må gerne udsættes til senere + +- Fuld 3D visualizer. +- Flere universer i UI. +- RDM configuration. +- DMX input. +- MIDI Clock. +- Ableton Link. +- Native Stream Deck plugin. +- Home Assistant integration. +- Companion module. +- Cloud access. +- Multi-node DMX. + +Arkitekturen må gerne forberede disse uden at gøre 1.0 unødvendigt kompliceret. + +--- + +# 33. Implementeringsrækkefølge + +Arbejd i disse faser og lav logiske git commits. + +## Fase 1 – Fundament + +- Repository. +- Backend. +- Frontend. +- Database. +- Login. +- Simulator. +- Health. +- Dark UI. +- CI. + +## Fase 2 – Fixture og patch + +- OFL client. +- Normalisering. +- Local cache. +- Fixture UI. +- Custom fixture. +- Patch. +- Conflict validation. + +## Fase 3 – DMX-engine + +- Frame model. +- Merge. +- Fades. +- Scene stack. +- Simulator output. +- OLA adapter. +- Reconnect. +- Telemetry. + +## Fase 4 – Scenes og effects + +- Scene editor. +- Launchpad. +- Chasers. +- Built-in effects. +- Safety. +- Restore state. + +## Fase 5 – BPM + +- Manual. +- Tap. +- Audio devices. +- JACK/ALSA. +- aubio. +- Beat event bus. +- BPM-sync effects. + +## Fase 6 – MixItUp + +- Tokens. +- Mapping UI. +- Trigger endpoints. +- Queue. +- Cooldowns. +- Presets. +- Test guide. + +## Fase 7 – Drift + +- systemd. +- Installer. +- Update. +- Backup. +- Diagnostics. +- Pi smoke test. +- Documentation. + +## Fase 8 – Hardening + +- Tests. +- Soak. +- Security review. +- Performance. +- Error states. +- Release package. + +--- + +# 34. Definition of Done + +Projektet er først færdigt, når: + +1. Det kan startes lokalt i simulator mode med én kommando. +2. Simulatoren viser alle 512 kanaler. +3. En fixture kan søges frem fra OFL og importeres. +4. En fixture kan patches uden adressekonflikt. +5. En scene kan oprettes gennem UI og aktiveres. +6. En chaser kan oprettes gennem UI. +7. En effekt kan afspilles og returnere til base-scenen. +8. Blackout overstyrer alt. +9. Strobe safety virker og er dækket af tests. +10. MixItUp-testendpoint returnerer hurtigt og trigger en effekt. +11. Manuel og tap BPM virker. +12. Audio BPM kan testes med syntetiske click tracks. +13. Telemetri viser current/target/source for DMX-kanaler. +14. OLA- og USB-fejl vises tydeligt. +15. Backup og restore virker i automatiske tests. +16. Autostart og systemd-filer er leveret. +17. `pi-smoke-test.sh` er leveret. +18. Hardwarevalideringsguiden er komplet. +19. Alle tests kører i CI. +20. Dokumentationen siger tydeligt, hvad der ikke er fysisk testet. +21. Ingen normal funktion kræver, at brugeren skriver kode i Web UI. +22. Der er ingen MIT-licens tilføjet automatisk. + +--- + +# 35. Acceptance tests på den rigtige Raspberry Pi + +Disse tests kan Codex ikke udføre. De skal leveres som en checkliste til Thomas. + +## Test A – Boot + +- Genstart Pi. +- OLA og TuxDMX starter automatisk. +- Web UI svarer. +- Safe scene er aktiv. + +## Test B – USB + +- Tilslut USB-DMX. +- Device vises i TuxDMX. +- Vælg port. +- Send testkanal. +- Lampen reagerer. + +## Test C – Reconnect + +- Træk USB-DMX ud. +- UI går til degraded/error. +- Tilslut igen. +- Systemet reconnecter. +- Ingen tilfældig flash. + +## Test D – Fixture + +- Importér den konkrete lampe fra OFL. +- Vælg korrekt mode. +- Patch korrekt adresse. +- Test alle relevante parametre. + +## Test E – Scene/effect + +- Opret base scene. +- Trigger sub-effect. +- Trigger raid oveni. +- Effektprioritet virker. +- Systemet vender tilbage til base scene. + +## Test F – MixItUp + +- Opret Web Request Action. +- Kald testtrigger. +- Se event i log. +- Se effekt. +- Bekræft hurtig HTTP response. +- Test cooldown og duplicate event. + +## Test G – BPM + +- Tilslut USB audio input. +- Vælg input. +- Afspil musik/click track. +- BPM og confidence vises. +- Beat-effekt følger rytmen. +- Test half/double. + +## Test H – Stabilitet + +- Kør minimum 2 timer med: + - Web UI åbent. + - BPM. + - Scener. + - Periodiske triggers. +- Kontrollér dropped frames, temperatur, RAM og logs. + +--- + +# 36. Kodestandard + +- Klar modulopdeling. +- Ingen gigantiske filer. +- Ingen skjulte globale states. +- Type annotations. +- Docstrings på public interfaces. +- Pydantic models ved API boundaries. +- Database migrations. +- Dependency lock. +- Central error handling. +- Structured logs. +- Unit-testbare services. +- Hardware interfaces skal kunne mockes. +- Secrets må ikke committes. +- `.env.example` uden hemmeligheder. +- Ingen default admin password. +- Ingen TODO-stubs i releasekritiske flows. +- Ingen fake success responses. +- Ingen swallow af exceptions uden logning. +- Ingen hårdkodet Raspberry Pi IP. +- Ingen hårdkodet USB device path. +- Ingen hårdkodede fixture channels. + +--- + +# 37. Vigtige designbeslutninger + +1. **OLA er hardwarebackend, ikke hele lyspulten.** + TuxDMX ejer scener, effects, UI, fixturelogik og Twitch-integration. + +2. **OFL normaliseres.** + Runtime må ikke afhænge direkte af en bestemt OFL schema-version. + +3. **Webrequests må ikke drive DMX synkront.** + De skal enqueue commands og returnere straks. + +4. **UI og DMX timing er adskilt.** + En langsom browser eller API-request må ikke skabe DMX-flimren. + +5. **Simulator er en førsteklasses backend.** + Ikke en eftertanke. + +6. **Telemetri er ærlig.** + Vis hvad software og OLA gør, men påstå ikke fysisk signalverifikation uden måling. + +7. **Safety har højere prioritet end Twitch.** + Blackout, master limit og strobe-limit kan aldrig omgås gennem integrationer. + +8. **Ingen kode kræves i UI.** + Avanceret funktionalitet bygges med forms og editors, ikke scripts. + +--- + +# 38. Første handling + +Start med at: + +1. Oprette repositorystrukturen. +2. Skrive `ARCHITECTURE.md` med konkrete valg. +3. Implementere simulatorbackend og DMX frame model. +4. Implementere minimal FastAPI health/status. +5. Implementere mørkt React-dashboard. +6. Oprette CI. +7. Oprette tests for 512-kanals frame, HTP/LTP og blackout. +8. Dokumentere hvordan simulatoren startes. + +Fortsæt derefter gennem implementeringsfaserne, indtil hele Release 1.0 er leveret. + +Når et valg er uklart, vælg den løsning der: + +- er lettest at vedligeholde, +- kører stabilt på Raspberry Pi, +- kan testes uden hardware, +- ikke kræver kode i UI, +- og ikke låser systemet til én bestemt USB-DMX-adapter. diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..2ac7bf4 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = %(here)s/alembic +sqlalchemy.url = sqlite+aiosqlite:///./data/tuxdmx.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..24a4956 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from app.core.config import get_alembic_database_url, get_settings # noqa: E402 +from app.models import entities # noqa: F401, E402 +from app.models.base import Base # noqa: E402 + +config = context.config +settings = get_settings() +alembic_url = get_alembic_database_url(settings.database_url) +config.set_main_option("sqlalchemy.url", alembic_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=alembic_url, + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/versions/20260712_0001_initial.py b/backend/alembic/versions/20260712_0001_initial.py new file mode 100644 index 0000000..cd07344 --- /dev/null +++ b/backend/alembic/versions/20260712_0001_initial.py @@ -0,0 +1,88 @@ +"""Initial TuxDMX schema.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "20260712_0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("username", sa.String(length=100), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("role", sa.String(length=30), nullable=False), + sa.Column("disabled", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_users_username", "users", ["username"], unique=True) + + op.create_table( + "settings", + sa.Column("key", sa.String(length=120), primary_key=True), + sa.Column("value", sa.JSON(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + + op.create_table( + "fixture_definitions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("slug", sa.String(length=160), nullable=False), + sa.Column("manufacturer", sa.String(length=120), nullable=False), + sa.Column("model", sa.String(length=160), nullable=False), + sa.Column("short_name", sa.String(length=120), nullable=True), + sa.Column("categories", sa.JSON(), nullable=False), + sa.Column("normalized_data", sa.JSON(), nullable=False), + sa.Column("source_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_fixture_definitions_slug", "fixture_definitions", ["slug"], unique=True) + + op.create_table( + "scenes", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("slug", sa.String(length=160), nullable=False), + sa.Column("color", sa.String(length=20), nullable=False), + sa.Column("icon", sa.String(length=40), nullable=True), + sa.Column("priority", sa.Integer(), nullable=False), + sa.Column("fade_in_ms", sa.Integer(), nullable=False), + sa.Column("fade_out_ms", sa.Integer(), nullable=False), + sa.Column("hold_ms", sa.Integer(), nullable=False), + sa.Column("master_limit", sa.Integer(), nullable=False), + sa.Column("values", sa.JSON(), nullable=False), + sa.Column("tags", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_scenes_slug", "scenes", ["slug"], unique=True) + + op.create_table( + "effects", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("slug", sa.String(length=160), nullable=False), + sa.Column("effect_type", sa.String(length=80), nullable=False), + sa.Column("parameters", sa.JSON(), nullable=False), + sa.Column("priority", sa.Integer(), nullable=False), + sa.Column("cooldown_ms", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_effects_slug", "effects", ["slug"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_effects_slug", table_name="effects") + op.drop_table("effects") + op.drop_index("ix_scenes_slug", table_name="scenes") + op.drop_table("scenes") + op.drop_index("ix_fixture_definitions_slug", table_name="fixture_definitions") + op.drop_table("fixture_definitions") + op.drop_table("settings") + op.drop_index("ix_users_username", table_name="users") + op.drop_table("users") diff --git a/backend/alembic/versions/20260719_0002_fixture_sources_and_patch.py b/backend/alembic/versions/20260719_0002_fixture_sources_and_patch.py new file mode 100644 index 0000000..d919ee8 --- /dev/null +++ b/backend/alembic/versions/20260719_0002_fixture_sources_and_patch.py @@ -0,0 +1,68 @@ +"""Add persisted fixture sources and patch instances.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "20260719_0002" +down_revision = "20260712_0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "fixture_sources" not in tables: + op.create_table( + "fixture_sources", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("manufacturer_key", sa.String(length=100), nullable=False), + sa.Column("fixture_key", sa.String(length=150), nullable=False), + sa.Column("schema_ref", sa.String(length=255), nullable=False), + sa.Column("source_url", sa.String(length=255), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("payload_hash", sa.String(length=64), nullable=False), + sa.Column("imported_at", sa.DateTime(timezone=True), nullable=False), + ) + + if "fixture_instances" not in tables: + op.create_table( + "fixture_instances", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("universe", sa.Integer(), nullable=False, server_default="1"), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("definition_id", sa.Integer(), nullable=False), + sa.Column("mode_key", sa.String(length=120), nullable=False), + sa.Column("start_address", sa.Integer(), nullable=False), + sa.Column("channel_count", sa.Integer(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("group_names", sa.JSON(), nullable=False), + sa.Column("position", sa.JSON(), nullable=False), + sa.Column("tags", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_fixture_instances_definition_id", "fixture_instances", ["definition_id"], unique=False) + else: + columns = {column["name"] for column in inspector.get_columns("fixture_instances")} + if "universe" not in columns: + with op.batch_alter_table("fixture_instances") as batch_op: + batch_op.add_column(sa.Column("universe", sa.Integer(), nullable=False, server_default="1")) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "fixture_instances" in tables: + columns = {column["name"] for column in inspector.get_columns("fixture_instances")} + if "universe" in columns: + with op.batch_alter_table("fixture_instances") as batch_op: + batch_op.drop_column("universe") + + if "fixture_sources" in tables: + op.drop_table("fixture_sources") diff --git a/backend/alembic/versions/20260722_0003_scene_targets.py b/backend/alembic/versions/20260722_0003_scene_targets.py new file mode 100644 index 0000000..1101cf5 --- /dev/null +++ b/backend/alembic/versions/20260722_0003_scene_targets.py @@ -0,0 +1,44 @@ +"""Add persisted scene targets.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "20260722_0003" +down_revision = "20260719_0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "scenes" not in tables: + return + + columns = {column["name"] for column in inspector.get_columns("scenes")} + if "targets" not in columns: + with op.batch_alter_table("scenes") as batch_op: + batch_op.add_column( + sa.Column( + "targets", + sa.JSON(), + nullable=False, + server_default=sa.text("'[]'"), + ) + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "scenes" not in tables: + return + + columns = {column["name"] for column in inspector.get_columns("scenes")} + if "targets" in columns: + with op.batch_alter_table("scenes") as batch_op: + batch_op.drop_column("targets") diff --git a/backend/alembic/versions/20260724_0004_midi_bridge.py b/backend/alembic/versions/20260724_0004_midi_bridge.py new file mode 100644 index 0000000..2ea5474 --- /dev/null +++ b/backend/alembic/versions/20260724_0004_midi_bridge.py @@ -0,0 +1,87 @@ +"""Add MIDI bridge, token and mapping tables.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "20260724_0004" +down_revision = "20260722_0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "midi_bridges" not in tables: + op.create_table( + "midi_bridges", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("bridge_id", sa.String(length=160), nullable=False), + sa.Column("device_name", sa.String(length=255), nullable=True), + sa.Column("ip_address", sa.String(length=64), nullable=True), + sa.Column("protocol_version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("online", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_event_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("last_event", sa.JSON(), nullable=False, server_default=sa.text("'{}'")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_midi_bridges_bridge_id", "midi_bridges", ["bridge_id"], unique=True) + + if "midi_bridge_tokens" not in tables: + op.create_table( + "midi_bridge_tokens", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("label", sa.String(length=120), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("bridge_id", sa.String(length=160), nullable=True), + sa.Column("scopes", sa.JSON(), nullable=False, server_default=sa.text("'[]'")), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_midi_bridge_tokens_token_hash", "midi_bridge_tokens", ["token_hash"], unique=True) + + if "midi_mappings" not in tables: + op.create_table( + "midi_mappings", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("bridge_id", sa.String(length=160), nullable=True), + sa.Column("device_name", sa.String(length=255), nullable=True), + sa.Column("message_type", sa.String(length=40), nullable=False), + sa.Column("channel", sa.Integer(), nullable=True), + sa.Column("number", sa.Integer(), nullable=False), + sa.Column("action", sa.String(length=60), nullable=False), + sa.Column("target_type", sa.String(length=40), nullable=False), + sa.Column("target_id", sa.String(length=160), nullable=True), + sa.Column("mode", sa.String(length=40), nullable=False, server_default="trigger"), + sa.Column("minimum_value", sa.Integer(), nullable=False, server_default="0"), + sa.Column("maximum_value", sa.Integer(), nullable=False, server_default="127"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "midi_mappings" in tables: + op.drop_table("midi_mappings") + + if "midi_bridge_tokens" in tables: + op.drop_index("ix_midi_bridge_tokens_token_hash", table_name="midi_bridge_tokens") + op.drop_table("midi_bridge_tokens") + + if "midi_bridges" in tables: + op.drop_index("ix_midi_bridges_bridge_id", table_name="midi_bridges") + op.drop_table("midi_bridges") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..11715ea --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,2 @@ +"""TuxDMX backend package.""" + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e95e744 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,2 @@ +"""API modules.""" + diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..fe82c54 --- /dev/null +++ b/backend/app/api/router.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from app.api.routes import router + +api_router = APIRouter() +api_router.include_router(router) + diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py new file mode 100644 index 0000000..35c0eca --- /dev/null +++ b/backend/app/api/routes.py @@ -0,0 +1,702 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request, Response, status + +from app.core.config import get_settings +from app.core.dependencies import app_state +from app.models.schemas import ( + BpmAudioConfigPayload, + BpmAudioStartPayload, + ChannelValue, + DmxOutputConfigPayload, + EffectPayload, + HealthResponse, + HomeAssistantConfigPayload, + HomeAssistantMappingPayload, + MidiBridgeEventPayload, + MidiBridgeHeartbeatPayload, + MidiBridgeTokenCreatePayload, + MidiLearnStartPayload, + MidiMappingPayload, + MidiTestMappingPayload, + ManualPatchValuesPayload, + PatchPayload, + PatchValidationPayload, + ScenePayload, + TriggerRequest, +) + +router = APIRouter() +settings = get_settings() + + +def _extract_bearer_token(request: Request) -> str | None: + authorization = request.headers.get("Authorization", "") + if not authorization.lower().startswith("bearer "): + return None + return authorization[7:].strip() or None + + +@router.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: + return HealthResponse( + status="ok", + simulator_enabled=settings.simulator_enabled, + version="1.0.0", + ) + + +@router.get("/system/status") +async def system_status() -> dict[str, object]: + return { + "app": settings.app_name, + "setup_required": False, + "engine": app_state.engine.snapshot(), + "telemetry": app_state.telemetry.snapshot(len(app_state.triggers.queue)), + "bpm": app_state.bpm.snapshot(), + } + + +@router.get("/system/diagnostics") +async def diagnostics() -> dict[str, object]: + return { + "engine": app_state.engine.snapshot(), + "telemetry": app_state.telemetry.snapshot(len(app_state.triggers.queue)), + "trigger_queue": app_state.triggers.snapshot(), + } + + +@router.post("/system/restart-service") +async def restart_service() -> dict[str, object]: + return await app_state.system.restart_service() + + +@router.post("/system/reboot-host") +async def reboot_host() -> dict[str, object]: + return await app_state.system.reboot_host() + + +@router.get("/dmx/status") +async def dmx_status() -> dict[str, object]: + return app_state.engine.snapshot() + + +@router.get("/dmx/devices") +async def dmx_devices() -> dict[str, object]: + status = app_state.engine.backend.get_status() + return { + "devices": [ + { + "name": status.device_name, + "backend": status.backend_name, + "connected": status.connected, + "output_port": status.selected_output_port, + "universe": status.selected_universe, + } + ] + } + + +@router.get("/dmx/config") +async def dmx_config() -> dict[str, object]: + return await app_state.dmx.get_config() + + +@router.put("/dmx/config") +async def dmx_config_save(payload: DmxOutputConfigPayload) -> dict[str, object]: + return await app_state.dmx.save_config(payload.model_dump()) + + +@router.post("/dmx/artnet/discover") +async def dmx_artnet_discover(timeout_s: float = 1.0) -> dict[str, object]: + return await app_state.dmx.discover_artnet(timeout_s) + + +@router.get("/dmx/universes") +async def dmx_universes() -> dict[str, object]: + status = app_state.engine.backend.get_status() + return { + "universes": [ + { + "id": status.selected_universe, + "frame_rate": settings.target_fps, + "output_port": status.selected_output_port, + } + ] + } + + +@router.get("/dmx/frame") +async def dmx_frame(universe: int | None = None) -> dict[str, object]: + selected_universe = universe or app_state.engine.backend.get_status().selected_universe + frame = app_state.engine.get_frame(selected_universe) + return { + "universe": frame.universe, + "values": frame.values, + "sources": frame.source_map, + } + + +@router.post("/dmx/test-channel") +async def dmx_test_channel(channel: int, value: int) -> dict[str, object]: + payload = ScenePayload( + name=f"Test kanal {channel}", + slug=f"test-channel-{channel}", + values=[ChannelValue(channel=channel, value=value, precedence="htp", source="test")], + ) + await app_state.scenes.save(payload) + await app_state.scenes.activate(payload.slug) + return {"status": "queued", "scene": payload} + + +@router.post("/dmx/blackout") +async def dmx_blackout() -> dict[str, str]: + app_state.engine.trigger_blackout() + return {"status": "blackout-active"} + + +@router.post("/dmx/release-blackout") +async def dmx_release_blackout() -> dict[str, str]: + app_state.engine.release_blackout() + return {"status": "blackout-released"} + + +@router.get("/live/mixer") +async def live_mixer() -> dict[str, object]: + return await app_state.live.snapshot() + + +@router.put("/live/mixer/{patch_id}") +async def live_mixer_set(patch_id: int, payload: ManualPatchValuesPayload) -> dict[str, object]: + try: + return await app_state.live.set_patch_values( + patch_id, + {int(channel): int(value) for channel, value in payload.values.items()}, + ) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/live/mixer/{patch_id}") +async def live_mixer_clear(patch_id: int) -> dict[str, object]: + return await app_state.live.clear_patch(patch_id) + + +@router.put("/live/mixer/home-assistant/{mapping_id}") +async def live_mixer_set_home_assistant(mapping_id: int, payload: ManualPatchValuesPayload) -> dict[str, object]: + try: + return await app_state.live.set_home_assistant_mapping_values( + mapping_id, + {int(channel): int(value) for channel, value in payload.values.items()}, + ) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/live/mixer/home-assistant/{mapping_id}") +async def live_mixer_clear_home_assistant(mapping_id: int) -> dict[str, object]: + try: + return await app_state.live.clear_home_assistant_mapping(mapping_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.get("/fixtures") +async def list_fixtures() -> dict[str, object]: + return {"items": await app_state.fixtures.list_fixtures()} + + +@router.post("/fixtures/search-ofl") +async def search_ofl(query: str) -> dict[str, object]: + return {"results": await app_state.fixtures.search_ofl(query)} + + +@router.post("/fixtures/import-ofl") +async def import_ofl(manufacturer_key: str, fixture_key: str) -> dict[str, object]: + return await app_state.fixtures.import_ofl(manufacturer_key, fixture_key) + + +@router.post("/fixtures/preview-ofl") +async def preview_ofl(manufacturer_key: str, fixture_key: str) -> dict[str, object]: + return await app_state.fixtures.preview_ofl(manufacturer_key, fixture_key) + + +@router.post("/fixtures/import-file") +async def import_fixture_file(payload: dict[str, object]) -> dict[str, object]: + return await app_state.fixtures.import_payload(payload) + + +@router.post("/fixtures/custom") +async def create_custom_fixture(payload: dict[str, object]) -> dict[str, object]: + return await import_fixture_file(payload) + + +@router.get("/fixtures/{fixture_id}") +async def get_fixture(fixture_id: int) -> dict[str, object]: + try: + return await app_state.fixtures.get_fixture(fixture_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail="Fixture not found") from exc + + +@router.put("/fixtures/{fixture_id}") +async def update_fixture(fixture_id: int, payload: dict[str, object]) -> dict[str, object]: + try: + return await app_state.fixtures.update_fixture(fixture_id, payload) + except LookupError as exc: + raise HTTPException(status_code=404, detail="Fixture not found") from exc + + +@router.delete("/fixtures/{fixture_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_fixture(fixture_id: int) -> Response: + try: + await app_state.fixtures.delete_fixture(fixture_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail="Fixture not found") from exc + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/patch") +async def patch_list() -> dict[str, object]: + return {"items": await app_state.patch.list_instances()} + + +@router.post("/patch", status_code=status.HTTP_201_CREATED) +async def patch_create(payload: PatchPayload) -> dict[str, object]: + try: + return await app_state.patch.create_instance(payload) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.put("/patch/{patch_id}") +async def patch_update(patch_id: int, payload: PatchPayload) -> dict[str, object]: + try: + return await app_state.patch.update_instance(patch_id, payload) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.delete("/patch/{patch_id}") +async def patch_delete(patch_id: int) -> dict[str, object]: + try: + return await app_state.patch.delete_instance(patch_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/patch/validate") +async def patch_validate(payload: PatchValidationPayload) -> dict[str, object]: + try: + return await app_state.patch.validate(payload) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.get("/scenes") +async def list_scenes() -> dict[str, object]: + return {"items": await app_state.scenes.list()} + + +@router.post("/scenes") +async def create_scene(payload: ScenePayload) -> dict[str, object]: + try: + return await app_state.scenes.save(payload) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.get("/scenes/{scene_id}") +async def get_scene(scene_id: int) -> dict[str, object]: + try: + return await app_state.scenes.get(scene_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.put("/scenes/{scene_id}") +async def update_scene(scene_id: int, payload: ScenePayload) -> dict[str, object]: + try: + return await app_state.scenes.save(payload, scene_id=scene_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.delete("/scenes/{scene_id}") +async def delete_scene(scene_id: int) -> dict[str, object]: + try: + return await app_state.scenes.delete(scene_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/scenes/{scene_slug}/activate") +async def activate_scene(scene_slug: str) -> dict[str, object]: + try: + return await app_state.scenes.activate(scene_slug) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/scenes/{scene_slug}/release") +async def release_scene(scene_slug: str) -> dict[str, str]: + await app_state.scenes.release(scene_slug) + return {"status": "released"} + + +@router.get("/effects") +async def list_effects() -> dict[str, object]: + return {"items": [effect.model_dump() for effect in app_state.effects.list()]} + + +@router.post("/effects") +async def create_effect(payload: EffectPayload) -> dict[str, object]: + return app_state.effects.save(payload).model_dump() + + +@router.put("/effects/{effect_id}") +async def update_effect(effect_id: int, payload: EffectPayload) -> dict[str, object]: + return {"id": effect_id, **app_state.effects.save(payload).model_dump()} + + +@router.delete("/effects/{effect_id}") +async def delete_effect(effect_id: int) -> dict[str, object]: + effects = app_state.effects.list() + if effect_id >= len(effects): + raise HTTPException(status_code=404, detail="Effect not found") + slug = effects[effect_id].slug + app_state.effects.effects.pop(slug, None) + return {"deleted": slug} + + +@router.post("/effects/{effect_slug}/trigger") +async def trigger_effect(effect_slug: str) -> dict[str, object]: + return app_state.effects.trigger(effect_slug).model_dump() + + +@router.post("/effects/{effect_slug}/stop") +async def stop_effect(effect_slug: str) -> dict[str, str]: + app_state.effects.stop(effect_slug) + return {"status": "stopped"} + + +@router.get("/bpm/status") +async def bpm_status() -> dict[str, object]: + return app_state.bpm.snapshot() + + +@router.post("/bpm/manual") +async def bpm_manual(bpm: float) -> dict[str, object]: + return app_state.bpm.set_manual(bpm) + + +@router.post("/bpm/tap") +async def bpm_tap() -> dict[str, object]: + return app_state.bpm.tap() + + +@router.post("/bpm/audio/start") +async def bpm_audio_start(payload: BpmAudioStartPayload) -> dict[str, object]: + try: + return await app_state.bpm.start_audio(payload.device) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/bpm/audio/stop") +async def bpm_audio_stop() -> dict[str, str]: + snapshot = await app_state.bpm.stop_audio() + return {"status": "stopped", "mode": str(snapshot["mode"])} + + +@router.get("/bpm/config") +async def bpm_config() -> dict[str, object]: + return await app_state.bpm.get_config() + + +@router.put("/bpm/config") +async def bpm_config_save(payload: BpmAudioConfigPayload) -> dict[str, object]: + return await app_state.bpm.save_config(payload.preferred_device) + + +@router.post("/bpm/external") +async def bpm_external(bpm: float) -> dict[str, object]: + return app_state.bpm.set_manual(bpm) + + +@router.get("/bpm/devices") +async def bpm_devices() -> dict[str, object]: + return {"devices": await app_state.bpm.list_devices()} + + +@router.get("/integrations/mixitup") +async def list_mixitup_integrations() -> dict[str, object]: + return {"items": []} + + +@router.post("/integrations/mixitup") +async def create_mixitup_integration(payload: dict[str, object]) -> dict[str, object]: + return payload + + +@router.put("/integrations/mixitup/{integration_id}") +async def update_mixitup_integration( + integration_id: int, + payload: dict[str, object], +) -> dict[str, object]: + return { + "id": integration_id, + **payload, + } + + +@router.delete("/integrations/mixitup/{integration_id}") +async def delete_mixitup_integration(integration_id: int) -> dict[str, object]: + return {"deleted": integration_id} + + +@router.post("/integrations/mixitup/{integration_id}/test") +async def test_mixitup_integration(integration_id: int) -> dict[str, object]: + return {"id": integration_id, "status": "accepted", "latency_target_ms": 200} + + +@router.get("/integrations/home-assistant/config") +async def home_assistant_config() -> dict[str, object]: + return await app_state.home_assistant.get_config() + + +@router.put("/integrations/home-assistant/config") +async def save_home_assistant_config(payload: HomeAssistantConfigPayload) -> dict[str, object]: + return await app_state.home_assistant.save_config(payload.model_dump()) + + +@router.post("/integrations/home-assistant/test-connection") +async def test_home_assistant_connection() -> dict[str, object]: + return await app_state.home_assistant.test_connection() + + +@router.get("/integrations/home-assistant/entities") +async def list_home_assistant_entities() -> dict[str, object]: + return await app_state.home_assistant.list_entities() + + +@router.get("/integrations/home-assistant/mappings") +async def list_home_assistant_mappings() -> dict[str, object]: + return await app_state.home_assistant.list_mappings() + + +@router.post("/integrations/home-assistant/mappings") +async def create_home_assistant_mapping(payload: HomeAssistantMappingPayload) -> dict[str, object]: + return await app_state.home_assistant.create_mapping(payload.model_dump()) + + +@router.put("/integrations/home-assistant/mappings/{mapping_id}") +async def update_home_assistant_mapping(mapping_id: int, payload: HomeAssistantMappingPayload) -> dict[str, object]: + try: + return await app_state.home_assistant.update_mapping(mapping_id, payload.model_dump()) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/integrations/home-assistant/mappings/{mapping_id}") +async def delete_home_assistant_mapping(mapping_id: int) -> dict[str, object]: + try: + return await app_state.home_assistant.delete_mapping(mapping_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/integrations/home-assistant/mappings/{mapping_id}/test") +async def test_home_assistant_mapping(mapping_id: int) -> dict[str, object]: + try: + return await app_state.home_assistant.test_mapping(mapping_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.get("/integrations/midi/bridges") +async def list_midi_bridges() -> dict[str, object]: + return await app_state.midi.list_bridges() + + +@router.get("/integrations/midi/mappings") +async def list_midi_mappings() -> dict[str, object]: + return await app_state.midi.list_mappings() + + +@router.post("/integrations/midi/mappings") +async def create_midi_mapping(payload: MidiMappingPayload) -> dict[str, object]: + try: + return await app_state.midi.create_mapping(payload.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.put("/integrations/midi/mappings/{mapping_id}") +async def update_midi_mapping(mapping_id: int, payload: MidiMappingPayload) -> dict[str, object]: + try: + return await app_state.midi.update_mapping(mapping_id, payload.model_dump()) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.delete("/integrations/midi/mappings/{mapping_id}") +async def delete_midi_mapping(mapping_id: int) -> dict[str, object]: + try: + return await app_state.midi.delete_mapping(mapping_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/integrations/midi/mappings/{mapping_id}/test") +async def test_midi_mapping(mapping_id: int, payload: MidiTestMappingPayload) -> dict[str, object]: + try: + return await app_state.midi.test_mapping(mapping_id, payload.value, payload.message_type) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.get("/integrations/midi/tokens") +async def list_midi_tokens() -> dict[str, object]: + return await app_state.midi.list_tokens() + + +@router.post("/integrations/midi/tokens") +async def create_midi_token(payload: MidiBridgeTokenCreatePayload) -> dict[str, object]: + return await app_state.midi.create_token(payload.model_dump()) + + +@router.delete("/integrations/midi/tokens/{token_id}") +async def revoke_midi_token(token_id: int) -> dict[str, object]: + try: + return await app_state.midi.revoke_token(token_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.get("/integrations/midi/learn") +async def get_midi_learn_state() -> dict[str, object]: + return await app_state.midi.get_learn_state() + + +@router.post("/integrations/midi/learn/start") +async def start_midi_learn(payload: MidiLearnStartPayload) -> dict[str, object]: + return await app_state.midi.start_learn(payload.timeout_seconds, payload.allow_passthrough) + + +@router.post("/integrations/midi/learn/cancel") +async def cancel_midi_learn() -> dict[str, object]: + return await app_state.midi.cancel_learn() + + +@router.post("/integrations/midi/bridge/heartbeat") +async def midi_bridge_heartbeat(request: Request, payload: MidiBridgeHeartbeatPayload) -> dict[str, object]: + try: + await app_state.midi.verify_token( + _extract_bearer_token(request), + "midi:heartbeat", + bridge_id=payload.bridge_id, + ) + return await app_state.midi.receive_heartbeat( + payload.model_dump(mode="json", exclude_none=True), + request.client.host if request.client else None, + ) + except PermissionError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/integrations/midi/bridge/events") +async def midi_bridge_events(request: Request, payload: MidiBridgeEventPayload) -> dict[str, object]: + try: + await app_state.midi.verify_token( + _extract_bearer_token(request), + "midi:events", + bridge_id=payload.bridge_id, + ) + return await app_state.midi.receive_event( + payload.model_dump(mode="json"), + request.client.host if request.client else None, + ) + except PermissionError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/triggers/{slug}", status_code=status.HTTP_202_ACCEPTED) +async def trigger_event(slug: str, payload: TriggerRequest) -> dict[str, object]: + return app_state.triggers.enqueue(slug, payload) + + +@router.get("/telemetry/live") +async def telemetry_live() -> dict[str, object]: + return app_state.telemetry.snapshot(len(app_state.triggers.queue)) + + +@router.get("/telemetry/history") +async def telemetry_history() -> dict[str, object]: + return {"samples": []} + + +@router.get("/events") +async def events() -> dict[str, object]: + return {"items": app_state.telemetry.snapshot()["events"]} + + +@router.get("/logs") +async def logs() -> dict[str, object]: + return {"items": app_state.telemetry.snapshot()["events"]} + + +@router.post("/backups") +async def create_backup() -> dict[str, object]: + backup = app_state.backups.create_backup() + return { + "status": "created", + "id": backup.id, + "label": backup.label, + "path": str(backup.archive_path), + "manifest": backup.manifest, + } + + +@router.get("/backups") +async def list_backups() -> dict[str, object]: + return { + "items": [ + { + "id": backup.id, + "label": backup.label, + "path": str(backup.archive_path), + "created_at": backup.created_at.isoformat(), + "manifest": backup.manifest, + } + for backup in app_state.backups.list_backups() + ] + } + + +@router.post("/backups/{backup_id}/restore") +async def restore_backup(backup_id: int) -> dict[str, object]: + restored = app_state.backups.restore_backup(str(backup_id)) + return { + "status": "restored", + "backup_id": restored.id, + "label": restored.label, + } + + +@router.delete("/backups/{backup_id}") +async def delete_backup(backup_id: int) -> dict[str, object]: + app_state.backups.delete_backup(str(backup_id)) + return {"deleted": backup_id} diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..277f3a8 --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1,2 @@ +"""Authentication services.""" + diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py new file mode 100644 index 0000000..4b062e0 --- /dev/null +++ b/backend/app/auth/service.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from argon2 import PasswordHasher + + +@dataclass(slots=True) +class SessionData: + session_id: str + csrf_token: str + expires_at: datetime + role: str + username: str + + +class AuthService: + def __init__(self) -> None: + self._hasher = PasswordHasher() + self._sessions: dict[str, SessionData] = {} + + def hash_password(self, password: str) -> str: + return self._hasher.hash(password) + + def verify_password(self, password: str, password_hash: str) -> bool: + try: + return self._hasher.verify(password_hash, password) + except Exception: + return False + + def create_session(self, username: str, role: str) -> SessionData: + session_id = secrets.token_hex(24) + csrf_token = secrets.token_hex(16) + expires_at = datetime.now(UTC) + timedelta(hours=12) + session = SessionData( + session_id=session_id, + csrf_token=csrf_token, + expires_at=expires_at, + role=role, + username=username, + ) + self._sessions[session_id] = session + return session + + def get_session(self, session_id: str | None) -> SessionData | None: + if session_id is None: + return None + session = self._sessions.get(session_id) + if session is None: + return None + if session.expires_at < datetime.now(UTC): + self._sessions.pop(session_id, None) + return None + return session + diff --git a/backend/app/backup/__init__.py b/backend/app/backup/__init__.py new file mode 100644 index 0000000..c224cea --- /dev/null +++ b/backend/app/backup/__init__.py @@ -0,0 +1,2 @@ +"""Backup and restore services.""" + diff --git a/backend/app/backup/service.py b/backend/app/backup/service.py new file mode 100644 index 0000000..7271ab0 --- /dev/null +++ b/backend/app/backup/service.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import json +import shutil +import zipfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + + +@dataclass(slots=True) +class BackupRecordData: + id: str + label: str + archive_path: Path + manifest: dict[str, object] + created_at: datetime + + +class BackupService: + def __init__(self, data_dir: Path, backup_dir: Path) -> None: + self.data_dir = data_dir + self.backup_dir = backup_dir + self.backup_dir.mkdir(parents=True, exist_ok=True) + + def create_backup(self, label: str | None = None) -> BackupRecordData: + created_at = datetime.now(UTC) + backup_id = created_at.strftime("%Y%m%d%H%M%S") + safe_label = label or f"backup-{backup_id}" + archive_path = self.backup_dir / f"{safe_label}.zip" + manifest: dict[str, Any] = { + "id": backup_id, + "label": safe_label, + "created_at": created_at.isoformat(), + "format_version": 1, + "included_paths": [], + } + + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for file_path in sorted(self.data_dir.rglob("*")): + if not file_path.is_file(): + continue + if self.backup_dir in file_path.parents: + continue + relative = file_path.relative_to(self.data_dir) + archive.write(file_path, arcname=f"data/{relative.as_posix()}") + manifest["included_paths"].append(f"data/{relative.as_posix()}") + archive.writestr("manifest.json", json.dumps(manifest, indent=2, ensure_ascii=True)) + + return BackupRecordData( + id=backup_id, + label=safe_label, + archive_path=archive_path, + manifest=manifest, + created_at=created_at, + ) + + def list_backups(self) -> list[BackupRecordData]: + backups: list[BackupRecordData] = [] + for archive_path in sorted(self.backup_dir.glob("*.zip"), reverse=True): + try: + with zipfile.ZipFile(archive_path) as archive: + manifest = json.loads(archive.read("manifest.json").decode("utf-8")) + except (KeyError, zipfile.BadZipFile, json.JSONDecodeError): + continue + backups.append( + BackupRecordData( + id=str(manifest["id"]), + label=str(manifest["label"]), + archive_path=archive_path, + manifest=manifest, + created_at=datetime.fromisoformat(str(manifest["created_at"])), + ) + ) + return backups + + def restore_backup(self, backup_id: str) -> BackupRecordData: + target = next((item for item in self.list_backups() if item.id == backup_id), None) + if target is None: + raise FileNotFoundError(f"Backup {backup_id} ikke fundet") + + self.create_backup(label=f"pre-restore-{backup_id}") + temp_dir = self.backup_dir / f"restore-{backup_id}" + if temp_dir.exists(): + shutil.rmtree(temp_dir) + temp_dir.mkdir(parents=True, exist_ok=True) + + try: + with zipfile.ZipFile(target.archive_path) as archive: + archive.extractall(temp_dir) + + restored_data_dir = temp_dir / "data" + self._clear_runtime_files() + + if restored_data_dir.exists(): + for file_path in sorted(restored_data_dir.rglob("*")): + if not file_path.is_file(): + continue + relative = file_path.relative_to(restored_data_dir) + destination = self.data_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(file_path, destination) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + return target + + def _clear_runtime_files(self) -> None: + for file_path in sorted(self.data_dir.rglob("*"), reverse=True): + if self.backup_dir == file_path or self.backup_dir in file_path.parents: + continue + if file_path.is_file(): + file_path.unlink() + elif file_path.is_dir(): + file_path.rmdir() + + def delete_backup(self, backup_id: str) -> None: + target = next((item for item in self.list_backups() if item.id == backup_id), None) + if target is None: + raise FileNotFoundError(f"Backup {backup_id} ikke fundet") + target.archive_path.unlink(missing_ok=True) diff --git a/backend/app/bpm/__init__.py b/backend/app/bpm/__init__.py new file mode 100644 index 0000000..9b73978 --- /dev/null +++ b/backend/app/bpm/__init__.py @@ -0,0 +1,2 @@ +"""BPM services.""" + diff --git a/backend/app/bpm/service.py b/backend/app/bpm/service.py new file mode 100644 index 0000000..d973baf --- /dev/null +++ b/backend/app/bpm/service.py @@ -0,0 +1,619 @@ +from __future__ import annotations + +import asyncio +import math +import sys +from array import array +from collections import deque +from dataclasses import dataclass +from datetime import UTC, datetime +from statistics import fmean, median +from time import monotonic +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.database import SessionLocal +from app.models.entities import Setting + + +BPM_AUDIO_SETTING_KEY = "bpm_audio" +AUTO_ALSA_DEVICE = "alsa:auto" +SB_ALIAS_DEVICE = "alsa:plughw:CARD=SB,DEV=0" +SB_FALLBACK_DEVICE = "alsa:plughw:1,0" +DEFAULT_ALSA_DEVICE = "alsa:default" +DEFAULT_SAMPLE_RATE = 44_100 +DEFAULT_CHANNELS = 1 +DEFAULT_AUDIO_FORMAT = "S16_LE" + + +@dataclass(slots=True) +class AudioDevice: + id: str + name: str + backend: str + is_default: bool = False + recommended: bool = False + + +class BeatAnalyzer: + def __init__(self, sample_rate: int = DEFAULT_SAMPLE_RATE, window_size: int = 1024) -> None: + self.sample_rate = sample_rate + self.window_size = window_size + self._buffer = array("h") + self._processed_samples = 0 + self._last_trigger_sample = -sample_rate + self._energy_history: deque[float] = deque(maxlen=48) + self._beat_times: deque[float] = deque(maxlen=16) + self.bpm = 120.0 + self.confidence = 0.0 + self.last_beat_at: float | None = None + self.current_level = 0.0 + self.peak_level = 0.0 + self.clipping = False + + def feed_pcm16(self, chunk: bytes) -> tuple[float, float] | None: + samples = array("h") + samples.frombytes(chunk[: len(chunk) - (len(chunk) % 2)]) + if sys.byteorder != "little": + samples.byteswap() + self._buffer.extend(samples) + + updated = False + while len(self._buffer) >= self.window_size: + window = self._buffer[: self.window_size] + del self._buffer[: self.window_size] + updated = self._process_window(window) or updated + self._processed_samples += len(window) + + if updated: + return self.bpm, self.confidence + return None + + def _process_window(self, window: array[int]) -> bool: + if not window: + return False + + energy = math.sqrt(sum(sample * sample for sample in window) / len(window)) / 32768.0 + peak = max(abs(sample) for sample in window) / 32768.0 + self.current_level = round(max(max(0.0, min(1.0, energy)), self.current_level * 0.82), 4) + self.peak_level = round(max(peak, self.peak_level * 0.92), 4) + self.clipping = peak >= 0.985 + baseline = fmean(self._energy_history) if self._energy_history else 0.03 + threshold = max(0.08, baseline * 2.4) + self._energy_history.append(energy) + + min_interval = int(self.sample_rate * 0.2) + if energy < threshold or self._processed_samples - self._last_trigger_sample < min_interval: + return False + + timestamp = self._processed_samples / self.sample_rate + self._beat_times.append(timestamp) + self._last_trigger_sample = self._processed_samples + self.last_beat_at = timestamp + + if len(self._beat_times) < 2: + return False + + intervals = [ + self._beat_times[index] - self._beat_times[index - 1] + for index in range(1, len(self._beat_times)) + ] + beat_seconds = median(intervals) + if beat_seconds <= 0: + return False + + bpm = self._normalize_bpm(60.0 / beat_seconds) + jitter = 0.0 + if len(intervals) > 1: + average = fmean(intervals) + jitter = fmean(abs(interval - average) for interval in intervals) + self.bpm = round(bpm, 2) + regularity = max(0.0, 1.0 - min(jitter / max(beat_seconds, 0.001), 1.0)) + self.confidence = round(min(1.0, regularity * min(1.0, len(intervals) / 6)), 3) + return True + + def _normalize_bpm(self, bpm: float) -> float: + while bpm < 70.0: + bpm *= 2.0 + while bpm > 180.0: + bpm /= 2.0 + return bpm + + +class BpmService: + def __init__(self, session_factory: async_sessionmaker[AsyncSession] = SessionLocal) -> None: + self._session_factory = session_factory + self.mode = "manual" + self.current_bpm = 120.0 + self.confidence = 1.0 + self.last_error: str | None = None + self.audio_connected = False + self.current_device = "manual" + self.selected_device = AUTO_ALSA_DEVICE + self.recommended_device = SB_ALIAS_DEVICE + self.beat_counter = 0 + self.last_beat_detected_at: float | None = None + self._tap_times: list[float] = [] + self._manual_bpm = 120.0 + self._audio_task: asyncio.Task[None] | None = None + self._audio_process: asyncio.subprocess.Process | None = None + self._analyzer = BeatAnalyzer() + self._device_cache: list[dict[str, object]] = [ + self._serialize_device( + AudioDevice( + id=AUTO_ALSA_DEVICE, + name="Auto (SB -> plughw:CARD=SB,DEV=0 -> plughw:1,0 -> default)", + backend="alsa", + is_default=True, + ) + ), + self._serialize_device(AudioDevice(id=DEFAULT_ALSA_DEVICE, name="ALSA default", backend="alsa")), + self._serialize_device( + AudioDevice(id="synthetic-click-track", name="Syntetisk click track", backend="synthetic") + ), + ] + + async def startup(self) -> None: + await self._load_config() + await self.list_devices() + + async def shutdown(self) -> None: + await self.stop_audio() + + def set_manual(self, bpm: float) -> dict[str, object]: + self._manual_bpm = float(bpm) + self.mode = "manual" + self.current_device = "manual" + self.current_bpm = round(float(bpm), 2) + self.confidence = 1.0 + self.audio_connected = False + self.last_error = None + self._cancel_audio_runtime() + return self.snapshot() + + def tap(self) -> dict[str, object]: + now = monotonic() + self._tap_times = [value for value in self._tap_times if now - value < 4.0] + self._tap_times.append(now) + if len(self._tap_times) >= 2: + intervals = [ + self._tap_times[index] - self._tap_times[index - 1] + for index in range(1, len(self._tap_times)) + ] + beat_seconds = median(intervals) + if beat_seconds > 0: + self.current_bpm = round(60 / beat_seconds, 2) + self._manual_bpm = self.current_bpm + self.mode = "tap" + self.current_device = "tap" + self.confidence = min(1.0, len(intervals) / 4) + self.audio_connected = False + self.last_error = None + self._mark_beat_event() + return self.snapshot() + + async def start_audio(self, device: str = AUTO_ALSA_DEVICE) -> dict[str, object]: + await self.stop_audio() + self._analyzer = BeatAnalyzer() + normalized_device = self._normalize_device_id(device) + self.mode = f"audio:{normalized_device}" + self.current_device = "pending" + self.confidence = 0.0 + self.audio_connected = False + self.last_error = None + + if normalized_device == "synthetic-click-track": + self._audio_task = asyncio.create_task(self._synthetic_click_loop(), name="tuxdmx-bpm-synthetic") + return self.snapshot() + + if normalized_device.startswith("alsa:"): + candidates = await self._resolve_alsa_candidates(normalized_device) + self._audio_task = asyncio.create_task( + self._alsa_capture_loop(candidates), + name="tuxdmx-bpm-alsa", + ) + return self.snapshot() + + raise LookupError(f"Ukendt BPM-device: {normalized_device}") + + async def stop_audio(self) -> dict[str, object]: + self._cancel_audio_runtime() + if self._audio_task is not None: + try: + await self._audio_task + except asyncio.CancelledError: + pass + if self._audio_process is not None: + try: + await self._audio_process.wait() + except ProcessLookupError: + pass + self._audio_task = None + self._audio_process = None + self.audio_connected = False + if self.mode.startswith("audio:"): + self.mode = "manual" + self.current_device = "manual" + self.current_bpm = self._manual_bpm + self.confidence = 1.0 + return self.snapshot() + + async def get_config(self) -> dict[str, object]: + return { + "preferred_device": self.selected_device, + "recommended_device": self.recommended_device, + "sample_rate": DEFAULT_SAMPLE_RATE, + "channels": DEFAULT_CHANNELS, + "format": DEFAULT_AUDIO_FORMAT, + } + + async def save_config(self, preferred_device: str) -> dict[str, object]: + self.selected_device = self._normalize_device_id(preferred_device) + await self.list_devices() + payload = await self.get_config() + async with self._session_factory() as session: + setting = await session.get(Setting, BPM_AUDIO_SETTING_KEY) + if setting is None: + setting = Setting(key=BPM_AUDIO_SETTING_KEY, value=payload, updated_at=datetime.now(UTC)) + session.add(setting) + else: + setting.value = payload + setting.updated_at = datetime.now(UTC) + await session.commit() + return await self.get_config() + + async def list_devices(self) -> list[dict[str, object]]: + devices: list[AudioDevice] = [] + self._append_device( + devices, + AudioDevice( + id=AUTO_ALSA_DEVICE, + name="Auto (SB -> plughw:CARD=SB,DEV=0 -> plughw:1,0 -> default)", + backend="alsa", + is_default=True, + ), + ) + self._append_device( + devices, + AudioDevice(id="synthetic-click-track", name="Syntetisk click track", backend="synthetic"), + ) + self._append_device(devices, AudioDevice(id=DEFAULT_ALSA_DEVICE, name="ALSA default", backend="alsa")) + + for device in await self._list_alsa_hardware_devices(): + self._append_device(devices, device) + + try: + process = await asyncio.create_subprocess_exec( + "arecord", + "-L", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except (FileNotFoundError, NotImplementedError): + self.recommended_device = self._pick_recommended_device(devices) + for device in devices: + device.recommended = device.id == self.recommended_device + self._device_cache = [self._serialize_device(device) for device in devices] + return self._device_cache + + stdout, _stderr = await process.communicate() + if process.returncode == 0: + for raw_line in stdout.decode("utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or raw_line.startswith(" "): + continue + self._append_device(devices, AudioDevice(id=f"alsa:{line}", name=line, backend="alsa")) + + self.recommended_device = self._pick_recommended_device(devices) + for device in devices: + device.recommended = device.id == self.recommended_device + self._device_cache = [self._serialize_device(device) for device in devices] + return self._device_cache + + def snapshot(self) -> dict[str, object]: + return { + "mode": self.mode, + "bpm": round(self.current_bpm, 2), + "confidence": round(self.confidence, 3), + "devices": [str(device["id"]) for device in self._device_cache], + "device_details": self._device_cache, + "current_device": self.current_device, + "selected_device": self.selected_device, + "recommended_device": self.recommended_device, + "audio_connected": self.audio_connected, + "last_error": self.last_error, + "last_beat_at": self._analyzer.last_beat_at, + "last_beat_detected_at": self.last_beat_detected_at, + "beat_counter": self.beat_counter, + "input_level": round(self._analyzer.current_level, 4), + "peak_level": round(self._analyzer.peak_level, 4), + "clipping": self._analyzer.clipping, + "sample_rate": DEFAULT_SAMPLE_RATE, + "channels": DEFAULT_CHANNELS, + "format": DEFAULT_AUDIO_FORMAT, + } + + async def _alsa_capture_loop(self, candidates: list[str]) -> None: + last_error: str | None = None + for candidate in candidates: + alsa_device = candidate.split(":", 1)[1] + try: + process = await asyncio.create_subprocess_exec( + "arecord", + "-D", + alsa_device, + "-q", + "-f", + DEFAULT_AUDIO_FORMAT, + "-c", + str(DEFAULT_CHANNELS), + "-r", + str(DEFAULT_SAMPLE_RATE), + "-t", + "raw", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + self._fallback_to_manual("arecord blev ikke fundet på systemet.") + return + + self._audio_process = process + self.current_device = candidate + self.mode = f"audio:{candidate}" + connected_once = False + stderr_output = b"" + + try: + if process.stdout is None: + last_error = "ALSA-capture kunne ikke starte." + continue + while True: + chunk = await process.stdout.read(4096) + if not chunk: + break + connected_once = True + self.audio_connected = True + result = self._analyzer.feed_pcm16(chunk) + if result is None: + continue + bpm, confidence = result + self.current_bpm = bpm + self.confidence = confidence + self._mark_beat_event() + except asyncio.CancelledError: + if process.returncode is None: + process.terminate() + await process.wait() + raise + finally: + if process.returncode is None: + process.terminate() + await process.wait() + if process.stderr is not None: + stderr_output = await process.stderr.read() + self._audio_process = None + + error = self._humanize_alsa_error( + candidate, + stderr_output.decode("utf-8", errors="ignore").strip(), + ) + if connected_once: + if self.mode.startswith("audio:"): + self._fallback_to_manual(error or "Audio-input stoppede.") + return + + last_error = error or f"Kunne ikke åbne ALSA-input {alsa_device}." + self.audio_connected = False + + if self.mode.startswith("audio:"): + self._fallback_to_manual(last_error or "Audio-input stoppede.") + + async def _synthetic_click_loop(self) -> None: + self.audio_connected = True + click_interval = 0.5 + sample_rate = self._analyzer.sample_rate + click_width = 2048 + silence_width = int(sample_rate * click_interval) - click_width + amplitude = 22000 + + click = array("h", [amplitude if index < 128 else 0 for index in range(click_width)]) + silence = array("h", [0] * max(0, silence_width)) + + try: + while True: + self._feed_array(click) + self._feed_array(silence) + await asyncio.sleep(click_interval) + except asyncio.CancelledError: + raise + finally: + self.audio_connected = False + + def _feed_array(self, values: array[int]) -> None: + if not values: + return + result = self._analyzer.feed_pcm16(values.tobytes()) + if result is None: + return + bpm, confidence = result + self.current_bpm = bpm + self.confidence = confidence + self._mark_beat_event() + + def _cancel_audio_runtime(self) -> None: + if self._audio_task is not None: + self._audio_task.cancel() + if self._audio_process is not None and self._audio_process.returncode is None: + self._audio_process.terminate() + + def _fallback_to_manual(self, error: str) -> None: + self.mode = "manual" + self.current_device = "manual" + self.current_bpm = self._manual_bpm + self.confidence = 1.0 + self.audio_connected = False + self.last_error = error + self._analyzer.current_level = 0.0 + self._analyzer.peak_level = 0.0 + self._analyzer.clipping = False + + def _mark_beat_event(self) -> None: + self.beat_counter += 1 + self.last_beat_detected_at = monotonic() + + def _serialize_device(self, device: AudioDevice) -> dict[str, Any]: + return { + "id": device.id, + "name": device.name, + "backend": device.backend, + "is_default": device.is_default, + "recommended": device.recommended, + } + + async def _list_alsa_hardware_devices(self) -> list[AudioDevice]: + try: + process = await asyncio.create_subprocess_exec( + "arecord", + "-l", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except (FileNotFoundError, NotImplementedError): + return [] + + stdout, _stderr = await process.communicate() + if process.returncode != 0: + return [] + + devices: list[AudioDevice] = [] + for raw_line in stdout.decode("utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + if not line or not line.startswith("card "): + continue + card = self._parse_card_line(line) + device = self._parse_device_from_card_line(line) + if card is None or device is None: + continue + card_index, card_key, card_name = card + device_index, _device_key, device_name = device + label = f"{card_name} / {device_name}" if device_name else card_name + self._append_device( + devices, + AudioDevice(id=f"alsa:hw:{card_index},{device_index}", name=label, backend="alsa"), + ) + self._append_device( + devices, + AudioDevice( + id=f"alsa:plughw:{card_index},{device_index}", + name=f"{label} (plughw)", + backend="alsa", + ), + ) + if card_key: + self._append_device( + devices, + AudioDevice( + id=f"alsa:plughw:CARD={card_key},DEV={device_index}", + name=f"{label} (stabil alias)", + backend="alsa", + ), + ) + return devices + + async def _load_config(self) -> None: + async with self._session_factory() as session: + setting = await session.get(Setting, BPM_AUDIO_SETTING_KEY) + if setting is None or not isinstance(setting.value, dict): + return + preferred = setting.value.get("preferred_device") + if isinstance(preferred, str) and preferred.strip(): + self.selected_device = self._normalize_device_id(preferred) + + async def _resolve_alsa_candidates(self, requested_device: str) -> list[str]: + await self.list_devices() + candidates: list[str] = [] + if requested_device == AUTO_ALSA_DEVICE: + if self.selected_device != AUTO_ALSA_DEVICE: + candidates.append(self.selected_device) + candidates.extend([self.recommended_device, SB_ALIAS_DEVICE, SB_FALLBACK_DEVICE, DEFAULT_ALSA_DEVICE]) + else: + candidates.append(requested_device) + if requested_device == SB_ALIAS_DEVICE: + candidates.extend([SB_FALLBACK_DEVICE, DEFAULT_ALSA_DEVICE]) + elif requested_device == SB_FALLBACK_DEVICE: + candidates.append(DEFAULT_ALSA_DEVICE) + + unique_candidates: list[str] = [] + for candidate in candidates: + normalized = self._normalize_device_id(candidate) + if not normalized.startswith("alsa:"): + continue + if normalized not in unique_candidates: + unique_candidates.append(normalized) + return unique_candidates or [DEFAULT_ALSA_DEVICE] + + def _append_device(self, devices: list[AudioDevice], device: AudioDevice) -> None: + if any(existing.id == device.id for existing in devices): + return + devices.append(device) + + def _pick_recommended_device(self, devices: list[AudioDevice]) -> str: + preferred_ids = [SB_ALIAS_DEVICE, SB_FALLBACK_DEVICE, DEFAULT_ALSA_DEVICE] + for preferred_id in preferred_ids: + if any(device.id == preferred_id for device in devices): + return preferred_id + for device in devices: + if "SB" in device.id.upper(): + return device.id + return DEFAULT_ALSA_DEVICE + + def _normalize_device_id(self, device: str) -> str: + normalized = device.strip() if device else AUTO_ALSA_DEVICE + if not normalized: + return AUTO_ALSA_DEVICE + if normalized == "default": + return DEFAULT_ALSA_DEVICE + if normalized.startswith("alsa:") or normalized == "synthetic-click-track": + return normalized + if normalized.startswith("plughw:") or normalized.startswith("hw:"): + return f"alsa:{normalized}" + return f"alsa:{normalized}" + + def _humanize_alsa_error(self, candidate: str, error: str) -> str: + if not error: + return f"Audio-input {candidate.split(':', 1)[1]} stoppede." + normalized = error.lower() + if "device or resource busy" in normalized: + return ( + "Mikrofonen er optaget af en anden proces. Luk PipeWire, PulseAudio eller en " + "anden TuxDMX/arecord-proces, eller vælg et andet ALSA-input." + ) + if "cannot get card index" in normalized or "no such file or directory" in normalized: + return f"ALSA-input {candidate.split(':', 1)[1]} blev ikke fundet." + if "audio open error" in normalized or "unable to open slave" in normalized: + return f"Kunne ikke åbne ALSA-input {candidate.split(':', 1)[1]}." + return error + + def _parse_card_line(self, line: str) -> tuple[str, str, str] | None: + try: + after_prefix = line.split("card ", 1)[1] + card_index = after_prefix.split(":", 1)[0].strip() + after_index = after_prefix.split(":", 1)[1] + card_key = after_index.split("[", 1)[0].split(",", 1)[0].strip() + card_name = after_index.split("[", 1)[1].split("]", 1)[0].strip() + return card_index, card_key, card_name + except IndexError: + return None + + def _parse_device_from_card_line(self, line: str) -> tuple[str, str, str] | None: + try: + after_device = line.split("device ", 1)[1] + device_index = after_device.split(":", 1)[0].strip() + after_index = after_device.split(":", 1)[1] + device_key = after_index.split("[", 1)[0].strip().rstrip(",") + device_name = after_index.split("[", 1)[1].split("]", 1)[0].strip() + return device_index, device_key, device_name + except IndexError: + return None diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..008f820 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,2 @@ +"""Core application services.""" + diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..e5577fe --- /dev/null +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..c2a2d95 --- /dev/null +++ b/backend/app/core/database.py @@ -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) diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py new file mode 100644 index 0000000..43f1a5a --- /dev/null +++ b/backend/app/core/dependencies.py @@ -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() diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..6c0f2c6 --- /dev/null +++ b/backend/app/core/logging.py @@ -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) diff --git a/backend/app/dmx/__init__.py b/backend/app/dmx/__init__.py new file mode 100644 index 0000000..db048ab --- /dev/null +++ b/backend/app/dmx/__init__.py @@ -0,0 +1,2 @@ +"""DMX runtime services.""" + diff --git a/backend/app/dmx/backends.py b/backend/app/dmx/backends.py new file mode 100644 index 0000000..f58f7ed --- /dev/null +++ b/backend/app/dmx/backends.py @@ -0,0 +1,496 @@ +from __future__ import annotations + +import asyncio +import socket +import struct +import threading +from array import array +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any, Protocol + +from app.dmx.frame import DmxFrame + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +@dataclass(slots=True) +class BackendStatus: + connected: bool = True + degraded: bool = False + last_error: str | None = None + reconnect_count: int = 0 + latency_ms: int = 5 + device_name: str = "Simulator universe 1" + connected_since: datetime = field(default_factory=utc_now) + backend_name: str = "simulator" + last_successful_frame: datetime | None = None + frames_sent: int = 0 + send_errors: int = 0 + selected_universe: int = 1 + selected_output_port: str | None = None + + +class DmxBackend: + async def startup(self) -> None: + return None + + async def shutdown(self) -> None: + return None + + async def send_frame(self, frame: DmxFrame) -> None: # pragma: no cover - interface + raise NotImplementedError + + def get_status(self) -> BackendStatus: # pragma: no cover - interface + raise NotImplementedError + + +@dataclass(slots=True) +class ArtNetNode: + ip: str + short_name: str + long_name: str + net: int + sub_switch: int + port_count: int + raw_port_address: int + + @property + def label(self) -> str: + name = self.long_name or self.short_name or self.ip + return f"{name} ({self.ip})" + + +class OlaClientAdapter(Protocol): + def open(self) -> None: ... + + def close(self) -> None: ... + + def send_frame(self, universe: int, values: list[int]) -> None: ... + + +class PythonOlaClientAdapter: + def __init__(self, timeout_s: float = 1.0) -> None: + self.timeout_s = timeout_s + self._wrapper_class: type[Any] | None = None + + def open(self) -> None: + if self._wrapper_class is not None: + return + try: + from ola.ClientWrapper import ClientWrapper + except ImportError as exc: # pragma: no cover - depends on Pi runtime + raise RuntimeError( + "Python OLA bindings blev ikke fundet. " + "Installer ola-python eller OLA's Python-modul." + ) from exc + self._wrapper_class = ClientWrapper + + def close(self) -> None: + self._wrapper_class = None + + def send_frame(self, universe: int, values: list[int]) -> None: + self.open() + assert self._wrapper_class is not None + + wrapper = self._wrapper_class() + client = wrapper.Client() + dmx_buffer = array("B", values) + completed = threading.Event() + result: dict[str, object] = { + "success": False, + "error": f"Timeout under afsendelse til olad efter {self.timeout_s:.2f}s", + } + + def stop_wrapper() -> None: + try: + wrapper.Stop() + except Exception: + return + + def on_timeout() -> None: + completed.set() + stop_wrapper() + + def callback(state: object | None = None) -> None: + if completed.is_set(): + return + result["success"] = self._state_succeeded(state) + if not result["success"]: + result["error"] = self._state_message(state) + completed.set() + stop_wrapper() + + timer = threading.Timer(self.timeout_s, on_timeout) + timer.daemon = True + timer.start() + try: + client.SendDmx(universe, dmx_buffer, callback) + wrapper.Run() + except Exception as exc: + raise RuntimeError(f"OLA SendDmx fejlede: {exc}") from exc + finally: + timer.cancel() + + if not bool(result["success"]): + raise RuntimeError(str(result["error"])) + + def _state_succeeded(self, state: object | None) -> bool: + if state is None: + return True + if isinstance(state, bool): + return state + for attribute in ("Succeeded", "succeeded", "Ok", "ok", "success"): + member = getattr(state, attribute, None) + if callable(member): + try: + return bool(member()) + except Exception: + continue + if member is not None: + return bool(member) + return True + + def _state_message(self, state: object | None) -> str: + if state is None: + return "Ukendt OLA-fejl" + for attribute in ("message", "error", "status"): + member = getattr(state, attribute, None) + if callable(member): + try: + value = member() + except Exception: + continue + if value: + return str(value) + elif member: + return str(member) + return str(state) + + +class OlaDmxBackend(DmxBackend): + def __init__( + self, + *, + universe: int = 1, + output_port: str | None = None, + send_timeout_s: float = 1.0, + adapter_factory: Callable[[], OlaClientAdapter] | None = None, + ) -> None: + self._adapter_factory = adapter_factory or ( + lambda: PythonOlaClientAdapter(timeout_s=send_timeout_s) + ) + self._adapter: OlaClientAdapter | None = None + self._has_connected_once = False + self._status = BackendStatus( + connected=False, + degraded=True, + last_error="Afventer forbindelse til olad", + backend_name="ola", + device_name="OLA DMX output", + selected_universe=universe, + selected_output_port=output_port, + ) + + async def startup(self) -> None: + return None + + async def shutdown(self) -> None: + await self._disconnect() + + async def send_frame(self, frame: DmxFrame) -> None: + await self._ensure_connected() + assert self._adapter is not None + + try: + await asyncio.to_thread( + self._adapter.send_frame, + self._status.selected_universe, + frame.values.copy(), + ) + except Exception as exc: + self._status.connected = False + self._status.degraded = True + self._status.last_error = str(exc) + self._status.send_errors += 1 + await self._disconnect() + raise + + self._status.connected = True + self._status.degraded = False + self._status.last_error = None + self._status.frames_sent += 1 + self._status.last_successful_frame = utc_now() + + def get_status(self) -> BackendStatus: + return self._status + + async def _ensure_connected(self) -> None: + if self._adapter is not None: + return + adapter = self._adapter_factory() + await asyncio.to_thread(adapter.open) + self._adapter = adapter + self._status.connected = True + self._status.degraded = False + self._status.last_error = None + if self._has_connected_once: + self._status.reconnect_count += 1 + else: + self._has_connected_once = True + self._status.connected_since = utc_now() + + async def _disconnect(self) -> None: + if self._adapter is None: + return + adapter = self._adapter + self._adapter = None + await asyncio.to_thread(adapter.close) + + +ARTNET_PORT = 6454 +ARTNET_HEADER = b"Art-Net\x00" +ARTNET_OPCODE_POLL = 0x2000 +ARTNET_OPCODE_POLL_REPLY = 0x2100 +ARTNET_OPCODE_DMX = 0x5000 + + +def build_artnet_dmx_packet(universe: int, values: list[int], sequence: int = 1) -> bytes: + dmx_values = bytes(array("B", values[:512] + [0] * max(0, 512 - len(values)))) + port_address = max(0, universe - 1) + sub_uni = port_address & 0xFF + net = (port_address >> 8) & 0x7F + length = len(dmx_values) + return b"".join( + [ + ARTNET_HEADER, + struct.pack("H", length), + dmx_values, + ] + ) + + +def build_artnet_poll_packet() -> bytes: + return b"".join( + [ + ARTNET_HEADER, + struct.pack(" ArtNetNode | None: + if len(packet) < 190 or not packet.startswith(ARTNET_HEADER): + return None + opcode = struct.unpack_from("H", packet, 172)[0] + sw_out_0 = packet[190] if len(packet) > 190 else 0 + raw_port_address = (net << 8) | sw_out_0 + return ArtNetNode( + ip=ip, + short_name=short_name, + long_name=long_name, + net=net, + sub_switch=sub_switch, + port_count=port_count, + raw_port_address=raw_port_address, + ) + + +def discover_artnet_nodes(timeout_s: float = 1.0) -> list[ArtNetNode]: + nodes: dict[str, ArtNetNode] = {} + poll_packet = build_artnet_poll_packet() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("", 0)) + sock.settimeout(max(0.05, timeout_s / 5)) + sock.sendto(poll_packet, ("255.255.255.255", ARTNET_PORT)) + deadline = datetime.now(UTC).timestamp() + timeout_s + while datetime.now(UTC).timestamp() < deadline: + try: + packet, _addr = sock.recvfrom(1024) + except socket.timeout: + continue + node = parse_artnet_poll_reply(packet) + if node is not None: + nodes[node.ip] = node + finally: + sock.close() + return sorted(nodes.values(), key=lambda item: (item.long_name or item.short_name or item.ip, item.ip)) + + +class ArtNetDmxBackend(DmxBackend): + def __init__( + self, + *, + universe: int = 1, + target_host: str = "255.255.255.255", + output_port: str | None = None, + ) -> None: + self._target_host = target_host + self._sequence = 0 + self._socket: socket.socket | None = None + self._status = BackendStatus( + connected=False, + degraded=True, + last_error="Afventer Artnet socket", + backend_name="artnet", + device_name=f"Art-Net output {target_host}", + selected_universe=universe, + selected_output_port=output_port or target_host, + ) + + async def startup(self) -> None: + if self._socket is not None: + return + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + self._socket = sock + self._status.connected = True + self._status.degraded = False + self._status.last_error = None + self._status.connected_since = utc_now() + + async def shutdown(self) -> None: + if self._socket is not None: + self._socket.close() + self._socket = None + self._status.connected = False + + async def send_frame(self, frame: DmxFrame) -> None: + if self._socket is None: + await self.startup() + assert self._socket is not None + self._sequence = (self._sequence + 1) % 256 + packet = build_artnet_dmx_packet(self._status.selected_universe, frame.values.copy(), self._sequence) + try: + self._socket.sendto(packet, (self._target_host, ARTNET_PORT)) + except OSError as exc: + self._status.connected = False + self._status.degraded = True + self._status.last_error = str(exc) + self._status.send_errors += 1 + raise RuntimeError(str(exc)) from exc + + self._status.connected = True + self._status.degraded = False + self._status.last_error = None + self._status.frames_sent += 1 + self._status.last_successful_frame = utc_now() + + def get_status(self) -> BackendStatus: + return self._status + + +class SimulatorDmxBackend(DmxBackend): + def __init__(self, universe: int = 1) -> None: + self._status = BackendStatus( + selected_universe=universe, + selected_output_port="simulator", + device_name=f"Simulator universe {universe}", + ) + self._history: deque[DmxFrame] = deque(maxlen=120) + self._faults: set[str] = set() + + def inject_fault(self, fault: str, enabled: bool) -> None: + if enabled: + self._faults.add(fault) + else: + self._faults.discard(fault) + + def history(self) -> list[DmxFrame]: + return list(self._history) + + async def send_frame(self, frame: DmxFrame) -> None: + if "ola_unavailable" in self._faults: + self._record_error("OLA utilgaengelig i simulator") + raise RuntimeError(self._status.last_error or "Simulatorfejl") + if "usb_disconnected" in self._faults: + self._record_error("USB-DMX er frakoblet i simulator") + raise RuntimeError(self._status.last_error or "Simulatorfejl") + if "send_timeout" in self._faults: + await asyncio.sleep(0.25) + self._record_error("Simuleret send timeout") + raise TimeoutError(self._status.last_error or "Simuleret send timeout") + + await asyncio.sleep(self._status.latency_ms / 1000) + was_connected = self._status.connected + self._status.connected = True + self._status.degraded = "slow_backend" in self._faults + self._status.last_error = None + self._status.frames_sent += 1 + self._status.last_successful_frame = utc_now() + if not was_connected: + self._status.connected_since = utc_now() + self._status.reconnect_count += 1 + self._history.append(frame.copy()) + + def get_status(self) -> BackendStatus: + return self._status + + def _record_error(self, message: str) -> None: + self._status.connected = False + self._status.degraded = True + self._status.last_error = message + self._status.send_errors += 1 + + +class FakeOlaClientAdapter: + def __init__( + self, + *, + output_port: str = "mock-port-1", + open_results: list[Exception | None] | None = None, + send_results: list[Exception | None] | None = None, + ) -> None: + self.output_port = output_port + self.open_results: deque[Exception | None] = deque(open_results or []) + self.send_results: deque[Exception | None] = deque(send_results or []) + self.is_open = False + self.open_calls = 0 + self.close_calls = 0 + self.sent_frames: list[tuple[int, list[int]]] = [] + + def open(self) -> None: + self.open_calls += 1 + if self.open_results: + outcome = self.open_results.popleft() + if outcome is not None: + raise outcome + self.is_open = True + + def close(self) -> None: + self.close_calls += 1 + self.is_open = False + + def send_frame(self, universe: int, values: list[int]) -> None: + if not self.is_open: + raise RuntimeError("Mock OLA-adapter er ikke aaben") + self.sent_frames.append((universe, values.copy())) + if self.send_results: + outcome = self.send_results.popleft() + if outcome is not None: + raise outcome + + +FakeOlaAdapter = FakeOlaClientAdapter diff --git a/backend/app/dmx/engine.py b/backend/app/dmx/engine.py new file mode 100644 index 0000000..a38152a --- /dev/null +++ b/backend/app/dmx/engine.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from collections import deque +from datetime import UTC, datetime + +from app.core.config import get_settings +from app.dmx.backends import DmxBackend, SimulatorDmxBackend +from app.dmx.frame import DmxFrame, FrameLayer, merge_layers +from app.telemetry.service import TelemetryService + +logger = logging.getLogger(__name__) + + +class DmxEngine: + def __init__(self, telemetry: TelemetryService, backend: DmxBackend | None = None) -> None: + self._settings = get_settings() + self.telemetry = telemetry + self.backend = backend or SimulatorDmxBackend() + self.layers: dict[str, FrameLayer] = {} + self.blackout = False + self.freeze = False + self.master = 255 + self.current_frame = DmxFrame() + self.current_frames: dict[int, DmxFrame] = {1: DmxFrame()} + self._task: asyncio.Task[None] | None = None + self._running = False + self._frame_times: deque[float] = deque(maxlen=120) + + @property + def is_running(self) -> bool: + return self._running + + async def start(self) -> None: + if self._running: + return + await self.backend.startup() + self._running = True + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + self._running = False + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + await self.backend.shutdown() + + def configure_backend(self, backend: DmxBackend) -> None: + self.backend = backend + + async def replace_backend(self, backend: DmxBackend) -> None: + was_running = self._running + if was_running: + await self.stop() + self.backend = backend + selected_universe = self.backend.get_status().selected_universe + self.current_frame = DmxFrame(universe=selected_universe) + self.current_frames = {selected_universe: self.current_frame.copy()} + if was_running: + await self.start() + + async def _loop(self) -> None: + interval = 1 / self._settings.target_fps + while self._running: + started = time.perf_counter() + try: + if not self.freeze: + frames = merge_layers(list(self.layers.values()), blackout=self.blackout) + if self.master < 255: + for frame in frames.values(): + frame.values = [int((value / 255) * self.master) for value in frame.values] + self.current_frames = {universe: frame.copy() for universe, frame in frames.items()} + selected_universe = self.backend.get_status().selected_universe + frame = self.current_frames.get(selected_universe, DmxFrame(universe=selected_universe)) + self.current_frame = frame.copy() + await self.backend.send_frame(frame) + self.telemetry.record_send_success(self.backend.get_status()) + self._frame_times.append(time.perf_counter() - started) + except Exception as exc: + logger.exception("DMX send failed") + self.telemetry.record_send_failure(str(exc), self.backend.get_status()) + elapsed = time.perf_counter() - started + await asyncio.sleep(max(0, interval - elapsed)) + + def set_layer(self, layer: FrameLayer) -> None: + self.layers[layer.name] = layer + + def remove_layer(self, name: str) -> None: + self.layers.pop(name, None) + + def trigger_blackout(self) -> None: + self.blackout = True + + def release_blackout(self) -> None: + self.blackout = False + + def get_frame(self, universe: int) -> DmxFrame: + frame = self.current_frames.get(universe) + if frame is None: + return DmxFrame(universe=universe) + return frame.copy() + + def snapshot(self) -> dict[str, object]: + status = self.backend.get_status() + fps = 0.0 + if self._frame_times: + average = sum(self._frame_times) / len(self._frame_times) + if average > 0: + fps = 1 / average + return { + "backend": status.backend_name, + "connected": status.connected, + "degraded": status.degraded, + "last_error": status.last_error, + "last_successful_frame": status.last_successful_frame.isoformat() + if status.last_successful_frame is not None + else None, + "frames_sent": status.frames_sent, + "send_errors": status.send_errors, + "reconnect_count": status.reconnect_count, + "selected_universe": status.selected_universe, + "selected_output_port": status.selected_output_port, + "available_universes": sorted(self.current_frames), + "blackout": self.blackout, + "freeze": self.freeze, + "master": self.master, + "fps": round(fps, 2), + "frame": self.current_frame.values, + "source_map": self.current_frame.source_map, + "updated_at": datetime.now(UTC).isoformat(), + } diff --git a/backend/app/dmx/frame.py b/backend/app/dmx/frame.py new file mode 100644 index 0000000..ef5accd --- /dev/null +++ b/backend/app/dmx/frame.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime + + +def clamp_channel(value: int) -> int: + return max(0, min(255, int(value))) + + +@dataclass(slots=True) +class FrameLayer: + name: str + priority: int + values_by_universe: dict[int, dict[int, int]] + precedence_map_by_universe: dict[int, dict[int, str]] = field(default_factory=dict) + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + @property + def values(self) -> dict[int, int]: + return self.values_by_universe.get(1, {}) + + @property + def precedence_map(self) -> dict[int, str]: + return self.precedence_map_by_universe.get(1, {}) + + @classmethod + def from_channel_values( + cls, + name: str, + priority: int, + values: dict[int, int], + precedence_map: dict[int, str] | None = None, + *, + universe: int = 1, + ) -> "FrameLayer": + return cls( + name=name, + priority=priority, + values_by_universe={universe: values}, + precedence_map_by_universe={universe: precedence_map or {}}, + ) + + +@dataclass(slots=True) +class DmxFrame: + universe: int = 1 + values: list[int] = field(default_factory=lambda: [0] * 512) + source_map: list[str] = field(default_factory=lambda: ["idle"] * 512) + + def set_channel(self, channel: int, value: int, source: str = "manual") -> None: + if not 1 <= channel <= 512: + raise ValueError("Channel must be in range 1..512") + self.values[channel - 1] = clamp_channel(value) + self.source_map[channel - 1] = source + + def copy(self) -> DmxFrame: + return DmxFrame(self.universe, self.values.copy(), self.source_map.copy()) + + +def merge_layers(layers: list[FrameLayer], blackout: bool = False) -> dict[int, DmxFrame]: + universes = sorted( + { + int(universe) + for layer in layers + for universe in layer.values_by_universe + } + ) or [1] + if blackout: + return {universe: DmxFrame(universe=universe) for universe in universes} + + ordered = sorted(layers, key=lambda layer: (layer.priority, layer.created_at)) + frames = {universe: DmxFrame(universe=universe) for universe in universes} + for universe, frame in frames.items(): + for channel in range(1, 513): + channel_candidates: list[tuple[FrameLayer, int]] = [] + for layer in ordered: + values = layer.values_by_universe.get(universe, {}) + if channel in values: + channel_candidates.append((layer, clamp_channel(values[channel]))) + if not channel_candidates: + continue + + precedence_map = channel_candidates[-1][0].precedence_map_by_universe.get(universe, {}) + precedence = precedence_map.get(channel, "ltp").lower() + if precedence == "htp": + chosen_layer, chosen_value = max(channel_candidates, key=lambda pair: pair[1]) + else: + chosen_layer, chosen_value = channel_candidates[-1] + frame.set_channel(channel, chosen_value, chosen_layer.name) + return frames diff --git a/backend/app/dmx/output_service.py b/backend/app/dmx/output_service.py new file mode 100644 index 0000000..88a90e9 --- /dev/null +++ b/backend/app/dmx/output_service.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Literal + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.config import get_settings +from app.core.database import SessionLocal +from app.dmx.backends import ( + ArtNetDmxBackend, + ArtNetNode, + OlaDmxBackend, + SimulatorDmxBackend, + discover_artnet_nodes, +) +from app.dmx.engine import DmxEngine +from app.models.entities import Setting + + +DMX_OUTPUT_SETTING_KEY = "dmx_output" +BackendKind = Literal["simulator", "ola", "artnet"] + + +class DmxOutputService: + def __init__( + self, + engine: DmxEngine, + session_factory: async_sessionmaker[AsyncSession] = SessionLocal, + ) -> None: + self.engine = engine + self._session_factory = session_factory + self._settings = get_settings() + self._config = self._default_config() + self._artnet_nodes: list[ArtNetNode] = [] + + async def startup(self) -> None: + await self._load_config() + self.engine.configure_backend(self._build_backend(self._config)) + + async def get_config(self) -> dict[str, object]: + return { + **self._config, + "artnet_nodes": [self._serialize_node(node) for node in self._artnet_nodes], + } + + async def save_config(self, payload: dict[str, object]) -> dict[str, object]: + normalized = self._normalize_config(payload) + self._config = normalized + await self._persist_config(normalized) + backend = self._build_backend(normalized) + if self.engine.is_running: + await self.engine.replace_backend(backend) + else: + self.engine.configure_backend(backend) + return await self.get_config() + + async def discover_artnet(self, timeout_s: float = 1.0) -> dict[str, object]: + nodes = await self._discover(timeout_s) + self._artnet_nodes = nodes + return { + "items": [self._serialize_node(node) for node in nodes], + "count": len(nodes), + } + + async def _discover(self, timeout_s: float) -> list[ArtNetNode]: + return await self._to_thread_discovery(timeout_s) + + async def _to_thread_discovery(self, timeout_s: float) -> list[ArtNetNode]: + import asyncio + + return await asyncio.to_thread(discover_artnet_nodes, timeout_s) + + async def _load_config(self) -> None: + async with self._session_factory() as session: + setting = await session.get(Setting, DMX_OUTPUT_SETTING_KEY) + if setting is None or not isinstance(setting.value, dict): + return + self._config = self._normalize_config(setting.value) + + async def _persist_config(self, config: dict[str, object]) -> None: + async with self._session_factory() as session: + setting = await session.get(Setting, DMX_OUTPUT_SETTING_KEY) + if setting is None: + setting = Setting( + key=DMX_OUTPUT_SETTING_KEY, + value=config, + updated_at=datetime.now(UTC), + ) + session.add(setting) + else: + setting.value = config + setting.updated_at = datetime.now(UTC) + await session.commit() + + def _default_config(self) -> dict[str, object]: + backend: BackendKind = "simulator" if self._settings.simulator_enabled else "ola" + return { + "backend": backend, + "universe": self._settings.ola_universe, + "output_port": self._settings.ola_output_port or "", + "target_host": "", + } + + def _normalize_config(self, payload: dict[str, object]) -> dict[str, object]: + backend = str(payload.get("backend", self._default_config()["backend"])).lower() + if backend not in {"simulator", "ola", "artnet"}: + backend = "simulator" + universe = int(payload.get("universe", self._settings.ola_universe) or self._settings.ola_universe) + output_port = str(payload.get("output_port", "") or "").strip() + target_host = str(payload.get("target_host", "") or "").strip() + if backend == "artnet" and not target_host: + target_host = "255.255.255.255" + if backend == "simulator": + output_port = "simulator" + return { + "backend": backend, + "universe": max(1, min(63999, universe)), + "output_port": output_port, + "target_host": target_host, + } + + def _build_backend(self, config: dict[str, object]): + backend = str(config["backend"]) + universe = int(config["universe"]) + output_port = str(config.get("output_port", "") or "") or None + if backend == "ola": + return OlaDmxBackend( + universe=universe, + output_port=output_port, + send_timeout_s=self._settings.ola_send_timeout_ms / 1000, + ) + if backend == "artnet": + target_host = str(config.get("target_host", "") or "255.255.255.255") + return ArtNetDmxBackend( + universe=universe, + target_host=target_host, + output_port=output_port or target_host, + ) + return SimulatorDmxBackend(universe=universe) + + def _serialize_node(self, node: ArtNetNode) -> dict[str, object]: + return { + "ip": node.ip, + "short_name": node.short_name, + "long_name": node.long_name, + "label": node.label, + "net": node.net, + "sub_switch": node.sub_switch, + "port_count": node.port_count, + "raw_port_address": node.raw_port_address, + } diff --git a/backend/app/effects/__init__.py b/backend/app/effects/__init__.py new file mode 100644 index 0000000..37d38e7 --- /dev/null +++ b/backend/app/effects/__init__.py @@ -0,0 +1,2 @@ +"""Effect runtime services.""" + diff --git a/backend/app/effects/service.py b/backend/app/effects/service.py new file mode 100644 index 0000000..0b1fd28 --- /dev/null +++ b/backend/app/effects/service.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from time import monotonic + +from app.bpm.service import BpmService +from app.dmx.engine import DmxEngine +from app.dmx.frame import FrameLayer +from app.models.schemas import EffectPayload + + +@dataclass(slots=True) +class ActiveEffect: + slug: str + started_at: float + duration_ms: int + sync_mode: str = "manual" + + +class EffectService: + def __init__(self, engine: DmxEngine, bpm: BpmService) -> None: + self.engine = engine + self.bpm = bpm + self.effects: dict[str, EffectPayload] = {} + self.active_effects: dict[str, ActiveEffect] = {} + self._effect_tasks: dict[str, asyncio.Task[None]] = {} + + def save(self, payload: EffectPayload) -> EffectPayload: + self.effects[payload.slug] = payload + return payload + + def list(self) -> list[EffectPayload]: + return list(self.effects.values()) + + def trigger(self, slug: str) -> EffectPayload: + effect = self.effects[slug] + self.stop(slug) + + if effect.effect_type == "beat-flash": + self._effect_tasks[slug] = asyncio.create_task( + self._run_beat_flash(effect), + name=f"tuxdmx-effect-{slug}", + ) + self.active_effects[slug] = ActiveEffect( + slug=slug, + started_at=monotonic(), + duration_ms=self._coerce_duration_ms(effect.parameters.get("duration_ms"), fallback=180), + sync_mode="bpm", + ) + return effect + + values_by_universe, precedence_map_by_universe = self._resolve_channel_maps(effect) + duration_ms = self._coerce_duration_ms(effect.parameters.get("duration_ms"), fallback=5000) + self.engine.set_layer( + FrameLayer( + name=f"effect:{slug}", + priority=effect.priority, + values_by_universe=values_by_universe, + precedence_map_by_universe=precedence_map_by_universe, + ) + ) + self.active_effects[slug] = ActiveEffect( + slug=slug, + started_at=monotonic(), + duration_ms=duration_ms, + sync_mode="static", + ) + return effect + + def stop(self, slug: str) -> None: + task = self._effect_tasks.pop(slug, None) + if task is not None: + task.cancel() + self.engine.remove_layer(f"effect:{slug}") + self.active_effects.pop(slug, None) + + async def shutdown(self) -> None: + for slug in list(self._effect_tasks): + self.stop(slug) + tasks = list(self._effect_tasks.values()) + self._effect_tasks.clear() + for task in tasks: + try: + await task + except asyncio.CancelledError: + pass + + async def _run_beat_flash(self, effect: EffectPayload) -> None: + slug = effect.slug + values_by_universe, precedence_map_by_universe = self._resolve_channel_maps(effect) + if not any(values_by_universe.values()): + return + + pulse_ms = self._coerce_duration_ms(effect.parameters.get("duration_ms"), fallback=180) + pulse_seconds = max(0.05, pulse_ms / 1000) + last_seen_beat = self.bpm.beat_counter + next_manual_pulse_at = monotonic() + + try: + while True: + bpm_value = max(40.0, min(220.0, float(self.bpm.current_bpm))) + beat_interval = max(0.2, 60.0 / bpm_value) + should_pulse = False + + if self.bpm.audio_connected: + if self.bpm.beat_counter != last_seen_beat: + last_seen_beat = self.bpm.beat_counter + should_pulse = True + else: + await asyncio.sleep(0.02) + continue + else: + now = monotonic() + if now >= next_manual_pulse_at: + next_manual_pulse_at = now + beat_interval + should_pulse = True + else: + await asyncio.sleep(min(0.05, next_manual_pulse_at - now)) + continue + + if not should_pulse: + continue + + self.engine.set_layer( + FrameLayer( + name=f"effect:{slug}", + priority=effect.priority, + values_by_universe=values_by_universe, + precedence_map_by_universe=precedence_map_by_universe, + ) + ) + await asyncio.sleep(min(pulse_seconds, beat_interval * 0.8)) + self.engine.remove_layer(f"effect:{slug}") + except asyncio.CancelledError: + self.engine.remove_layer(f"effect:{slug}") + raise + + def _resolve_channel_maps(self, effect: EffectPayload) -> tuple[dict[int, dict[int, int]], dict[int, dict[int, str]]]: + raw_channels = effect.parameters.get("channels", {}) + raw_precedence = effect.parameters.get("precedence", {}) + channels = raw_channels if isinstance(raw_channels, dict) else {} + precedence = raw_precedence if isinstance(raw_precedence, dict) else {} + values = { + int(channel): int(value) + for channel, value in channels.items() + } + precedence_map = { + int(channel): str(value) + for channel, value in precedence.items() + } + universe = int(effect.parameters.get("universe", 1) or 1) + return {universe: values}, {universe: precedence_map} + + def _coerce_duration_ms(self, raw_value: object, fallback: int) -> int: + if isinstance(raw_value, (int, float)): + return max(50, int(raw_value)) + if isinstance(raw_value, str): + try: + return max(50, int(float(raw_value))) + except ValueError: + return fallback + return fallback diff --git a/backend/app/fixtures/__init__.py b/backend/app/fixtures/__init__.py new file mode 100644 index 0000000..4ecaa63 --- /dev/null +++ b/backend/app/fixtures/__init__.py @@ -0,0 +1,2 @@ +"""Fixture import and normalization.""" + diff --git a/backend/app/fixtures/normalize.py b/backend/app/fixtures/normalize.py new file mode 100644 index 0000000..8f08675 --- /dev/null +++ b/backend/app/fixtures/normalize.py @@ -0,0 +1,107 @@ +from __future__ import annotations + + +def normalize_fixture( + payload: dict[str, object], manufacturer_key: str, fixture_key: str +) -> dict[str, object]: + modes = payload.get("modes", []) + available_channels = payload.get("availableChannels", {}) + normalized_modes: list[dict[str, object]] = [] + for mode in modes if isinstance(modes, list) else []: + channels = mode.get("channels", []) if isinstance(mode, dict) else [] + normalized_channels: list[dict[str, object]] = [] + for index, channel in enumerate(channels, start=1): + if isinstance(channel, str): + channel_definition = ( + available_channels.get(channel, {}) + if isinstance(available_channels, dict) + else {} + ) + capability = ( + channel_definition.get("capability") + if isinstance(channel_definition, dict) + else None + ) + capabilities = ( + channel_definition.get("capabilities") + if isinstance(channel_definition, dict) + else None + ) + channel_capabilities = ( + capabilities + if isinstance(capabilities, list) + else [capability] + if capability is not None + else [] + ) + capability_types = [ + str(item.get("type", "")).lower() + for item in channel_capabilities + if isinstance(item, dict) + ] + normalized_channels.append( + { + "index": index, + "key": channel, + "display_name": channel.replace("-", " ").title(), + "precedence": ( + "htp" + if "dim" in channel.lower() or "intensity" in capability_types + else "ltp" + ), + "resolution": 8, + "capabilities": channel_capabilities, + } + ) + elif isinstance(channel, dict): + key = str( + channel.get("name", channel.get("key", channel.get("display_name", f"channel-{index}"))) + ) + capability = channel.get("capability") + capabilities = channel.get("capabilities") + channel_capabilities = ( + capabilities + if isinstance(capabilities, list) + else [capability] + if capability is not None + else [] + ) + normalized_channels.append( + { + "index": index, + "key": key, + "display_name": str(channel.get("display_name", key)), + "precedence": str( + channel.get( + "precedence", + "htp" if "dim" in key.lower() else "ltp", + ) + ).lower(), + "resolution": int(channel.get("resolution", 8)), + "capabilities": channel_capabilities, + } + ) + normalized_modes.append( + { + "key": ( + str(mode.get("name", mode.get("key", "default"))) + if isinstance(mode, dict) + else "default" + ), + "channel_count": len(normalized_channels), + "channels": normalized_channels, + } + ) + return { + "manufacturer": manufacturer_key, + "model": payload.get("name", fixture_key), + "short_name": payload.get("shortName"), + "categories": payload.get("categories", []), + "modes": normalized_modes, + "schema_version": payload.get("$schema", "unknown"), + "source": { + "manufacturer_key": manufacturer_key, + "fixture_key": fixture_key, + }, + "warnings": [], + } diff --git a/backend/app/fixtures/ofl.py b/backend/app/fixtures/ofl.py new file mode 100644 index 0000000..ae62207 --- /dev/null +++ b/backend/app/fixtures/ofl.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import httpx + + +class OflClient: + search_url = "https://open-fixture-library.org/api/v1/get-search-results" + fixture_base = "https://open-fixture-library.org" + + async def search(self, query: str) -> list[dict[str, object]]: + fallback = self._search_local(query) + try: + async with httpx.AsyncClient(timeout=6.0) as client: + response = await client.post( + self.search_url, + json={ + "searchQuery": query, + "manufacturersQuery": [], + "categoriesQuery": [], + }, + ) + response.raise_for_status() + keys = response.json() + return [{"fixture_key": key, "cached": False} for key in keys] + except Exception: + return fallback + + async def fetch_fixture(self, manufacturer_key: str, fixture_key: str) -> dict[str, object]: + local_path = Path("test-data/fixtures") / f"{manufacturer_key}__{fixture_key}.json" + if local_path.exists(): + return json.loads(local_path.read_text(encoding="utf-8")) + + url = f"{self.fixture_base}/{manufacturer_key}/{fixture_key}.json" + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(url) + response.raise_for_status() + return response.json() + + def _search_local(self, query: str) -> list[dict[str, object]]: + results: list[dict[str, object]] = [] + lowered = query.lower() + for path in Path("test-data/fixtures").glob("*.json"): + if lowered in path.stem.lower(): + results.append({"fixture_key": path.stem.replace("__", "/"), "cached": True}) + return results diff --git a/backend/app/fixtures/service.py b/backend/app/fixtures/service.py new file mode 100644 index 0000000..3da5c93 --- /dev/null +++ b/backend/app/fixtures/service.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.database import SessionLocal +from app.fixtures.normalize import normalize_fixture +from app.fixtures.ofl import OflClient +from app.models.entities import FixtureDefinition, FixtureInstance, FixtureSource + + +class FixtureService: + def __init__( + self, + session_factory: async_sessionmaker[AsyncSession] = SessionLocal, + client: OflClient | None = None, + ) -> None: + self._session_factory = session_factory + self.client = client or OflClient() + self.test_data_path = Path("test-data/fixtures") + + async def search_ofl(self, query: str) -> list[dict[str, object]]: + if not query.strip(): + return [] + return await self.client.search(query) + + async def preview_ofl(self, manufacturer_key: str, fixture_key: str) -> dict[str, object]: + fixture = await self.client.fetch_fixture(manufacturer_key, fixture_key) + return normalize_fixture(fixture, manufacturer_key, fixture_key) + + async def import_ofl(self, manufacturer_key: str, fixture_key: str) -> dict[str, object]: + source_payload = await self.client.fetch_fixture(manufacturer_key, fixture_key) + normalized = normalize_fixture(source_payload, manufacturer_key, fixture_key) + return await self._upsert_fixture(normalized, source_payload) + + async def import_payload(self, payload: dict[str, object]) -> dict[str, object]: + normalized = self._normalize_import_payload(payload) + return await self._upsert_fixture(normalized, payload) + + async def list_fixtures(self) -> list[dict[str, object]]: + async with self._session_factory() as session: + result = await session.execute(select(FixtureDefinition).order_by(FixtureDefinition.created_at, FixtureDefinition.id)) + definitions = result.scalars().all() + return [self._serialize_definition(definition) for definition in definitions] + + async def get_fixture(self, fixture_id: int) -> dict[str, object]: + async with self._session_factory() as session: + definition = await session.get(FixtureDefinition, fixture_id) + if definition is None: + raise LookupError("Fixture not found") + return self._serialize_definition(definition) + + async def update_fixture(self, fixture_id: int, payload: dict[str, object]) -> dict[str, object]: + async with self._session_factory() as session: + definition = await session.get(FixtureDefinition, fixture_id) + if definition is None: + raise LookupError("Fixture not found") + + existing = dict(definition.normalized_data) + merged = dict(existing) + for key in ("manufacturer", "model", "short_name", "categories", "modes", "schema_version", "source", "warnings"): + if key in payload: + merged[key] = payload[key] + + definition.manufacturer = str(merged.get("manufacturer", definition.manufacturer)) + definition.model = str(merged.get("model", definition.model)) + definition.short_name = self._optional_str(merged.get("short_name")) + definition.categories = self._string_list(merged.get("categories", [])) + definition.normalized_data = merged + await session.commit() + await session.refresh(definition) + return self._serialize_definition(definition) + + async def delete_fixture(self, fixture_id: int) -> None: + async with self._session_factory() as session: + definition = await session.get(FixtureDefinition, fixture_id) + if definition is None: + raise LookupError("Fixture not found") + + await session.execute(delete(FixtureInstance).where(FixtureInstance.definition_id == fixture_id)) + source_id = definition.source_id + await session.delete(definition) + await session.flush() + if source_id is not None: + result = await session.execute( + select(FixtureDefinition).where( + FixtureDefinition.source_id == source_id, + FixtureDefinition.id != fixture_id, + ).limit(1) + ) + if result.scalar_one_or_none() is None: + source = await session.get(FixtureSource, source_id) + if source is not None: + await session.delete(source) + await session.commit() + + async def _upsert_fixture( + self, + normalized: dict[str, object], + source_payload: dict[str, object], + ) -> dict[str, object]: + source = normalized.get("source", {}) + manufacturer_key = str(source.get("manufacturer_key", "custom")) if isinstance(source, dict) else "custom" + fixture_key = str(source.get("fixture_key", "custom")) if isinstance(source, dict) else "custom" + slug = f"{manufacturer_key}/{fixture_key}" + payload_hash = hashlib.sha256(json.dumps(source_payload, sort_keys=True).encode("utf-8")).hexdigest() + schema_ref = str(normalized.get("schema_version", "unknown")) + source_url = str(source_payload.get("oflURL", "")) if isinstance(source_payload, dict) else "" + + async with self._session_factory() as session: + fixture_source = await self._upsert_source( + session, + manufacturer_key=manufacturer_key, + fixture_key=fixture_key, + schema_ref=schema_ref, + source_url=source_url, + payload=source_payload, + payload_hash=payload_hash, + ) + + result = await session.execute(select(FixtureDefinition).where(FixtureDefinition.slug == slug)) + definition = result.scalar_one_or_none() + if definition is None: + definition = FixtureDefinition( + slug=slug, + manufacturer=str(normalized.get("manufacturer", manufacturer_key)), + model=str(normalized.get("model", fixture_key)), + short_name=self._optional_str(normalized.get("short_name")), + categories=self._string_list(normalized.get("categories", [])), + normalized_data=normalized, + source_id=fixture_source.id, + ) + session.add(definition) + else: + definition.manufacturer = str(normalized.get("manufacturer", definition.manufacturer)) + definition.model = str(normalized.get("model", definition.model)) + definition.short_name = self._optional_str(normalized.get("short_name")) + definition.categories = self._string_list(normalized.get("categories", [])) + definition.normalized_data = normalized + definition.source_id = fixture_source.id + + await session.commit() + await session.refresh(definition) + return self._serialize_definition(definition) + + async def _upsert_source( + self, + session: AsyncSession, + *, + manufacturer_key: str, + fixture_key: str, + schema_ref: str, + source_url: str, + payload: dict[str, object], + payload_hash: str, + ) -> FixtureSource: + result = await session.execute( + select(FixtureSource).where( + FixtureSource.manufacturer_key == manufacturer_key, + FixtureSource.fixture_key == fixture_key, + ) + ) + fixture_source = result.scalar_one_or_none() + if fixture_source is None: + fixture_source = FixtureSource( + manufacturer_key=manufacturer_key, + fixture_key=fixture_key, + schema_ref=schema_ref, + source_url=source_url, + payload=payload, + payload_hash=payload_hash, + ) + session.add(fixture_source) + await session.flush() + return fixture_source + + fixture_source.schema_ref = schema_ref + fixture_source.source_url = source_url + fixture_source.payload = payload + fixture_source.payload_hash = payload_hash + await session.flush() + return fixture_source + + def _serialize_definition(self, definition: FixtureDefinition) -> dict[str, object]: + normalized = dict(definition.normalized_data) + return { + "id": definition.id, + "slug": definition.slug, + "manufacturer": definition.manufacturer, + "model": definition.model, + "short_name": definition.short_name, + "categories": definition.categories, + "modes": normalized.get("modes", []), + "schema_version": normalized.get("schema_version", "unknown"), + "source": normalized.get("source", {}), + "warnings": normalized.get("warnings", []), + } + + def _normalize_import_payload(self, payload: dict[str, object]) -> dict[str, object]: + if self._looks_like_ofl_fixture(payload): + source_payload = payload.get("source", {}) + source = source_payload if isinstance(source_payload, dict) else {} + manufacturer_key = str(source.get("manufacturer_key", payload.get("manufacturer_key", "custom"))) + fixture_key = str( + source.get( + "fixture_key", + payload.get("fixture_key", str(payload.get("name", "custom-fixture")).lower().replace(" ", "-")), + ) + ) + normalized = normalize_fixture(payload, manufacturer_key, fixture_key) + manufacturer_name = payload.get("manufacturer") + if manufacturer_name not in (None, ""): + normalized["manufacturer"] = str(manufacturer_name) + return normalized + return self._normalize_custom_payload(payload) + + def _normalize_custom_payload(self, payload: dict[str, object]) -> dict[str, object]: + manufacturer = str(payload.get("manufacturer", "custom")) + model = str(payload.get("model", payload.get("name", "Custom fixture"))) + source_payload = payload.get("source", {}) + source = source_payload if isinstance(source_payload, dict) else {} + manufacturer_key = str(source.get("manufacturer_key", manufacturer.lower().replace(" ", "-"))) + fixture_key = str(source.get("fixture_key", model.lower().replace(" ", "-"))) + raw_modes = payload.get("modes", []) + modes: list[dict[str, object]] = [] + for index, mode in enumerate(raw_modes if isinstance(raw_modes, list) else [], start=1): + if not isinstance(mode, dict): + continue + raw_channels = mode.get("channels", []) + channels: list[dict[str, object]] = [] + for channel_index, channel in enumerate(raw_channels if isinstance(raw_channels, list) else [], start=1): + if isinstance(channel, dict): + channels.append( + { + "index": int(channel.get("index", channel_index)), + "key": str(channel.get("key", channel.get("display_name", f"channel-{channel_index}"))), + "display_name": str(channel.get("display_name", channel.get("key", f"Channel {channel_index}"))), + "precedence": str(channel.get("precedence", "ltp")).lower(), + "resolution": int(channel.get("resolution", 8)), + "capabilities": channel.get("capabilities", []), + } + ) + elif isinstance(channel, str): + channels.append( + { + "index": channel_index, + "key": channel, + "display_name": channel, + "precedence": "htp" if "dim" in channel.lower() else "ltp", + "resolution": 8, + "capabilities": [], + } + ) + modes.append( + { + "key": str(mode.get("key", mode.get("name", f"mode-{index}"))), + "channel_count": int(mode.get("channel_count", len(channels))), + "channels": channels, + } + ) + return { + "manufacturer": manufacturer, + "model": model, + "short_name": payload.get("short_name"), + "categories": self._string_list(payload.get("categories", [])), + "modes": modes, + "schema_version": str(payload.get("schema_version", "custom")), + "source": { + "manufacturer_key": manufacturer_key, + "fixture_key": fixture_key, + }, + "warnings": payload.get("warnings", []), + } + + def _string_list(self, values: object) -> list[str]: + if not isinstance(values, list): + return [] + return [str(value) for value in values] + + def _optional_str(self, value: object) -> str | None: + return str(value) if value not in (None, "") else None + + def _looks_like_ofl_fixture(self, payload: dict[str, object]) -> bool: + return isinstance(payload.get("availableChannels"), dict) and isinstance(payload.get("modes"), list) diff --git a/backend/app/homeassistant/service.py b/backend/app/homeassistant/service.py new file mode 100644 index 0000000..f8e743e --- /dev/null +++ b/backend/app/homeassistant/service.py @@ -0,0 +1,869 @@ +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from datetime import UTC, datetime +from time import monotonic +from typing import Any, Protocol + +import httpx +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.database import SessionLocal +from app.dmx.engine import DmxEngine +from app.models.entities import Setting + + +logger = logging.getLogger(__name__) + +HA_CONFIG_KEY = "home_assistant_config" +HA_MAPPINGS_KEY = "home_assistant_mappings" +SWITCH_ON_THRESHOLD = 140 +SWITCH_OFF_THRESHOLD = 115 +EDGE_TRIGGER_THRESHOLD = 128 +ON_OFF_ENTITY_DOMAINS = {"light", "switch", "input_boolean"} +TRIGGER_ENTITY_DOMAINS = {"scene", "automation"} + + +class HomeAssistantAdapter(Protocol): + async def get_api_root(self, base_url: str, token: str) -> dict[str, object]: ... + + async def get_config(self, base_url: str, token: str) -> dict[str, object]: ... + + async def list_entities(self, base_url: str, token: str) -> list[dict[str, object]]: ... + + async def call_service( + self, + base_url: str, + token: str, + domain: str, + service: str, + data: dict[str, object], + ) -> dict[str, object]: ... + + +class HttpxHomeAssistantAdapter: + def __init__(self, timeout_s: float = 2.0) -> None: + self.timeout_s = timeout_s + + async def get_api_root(self, base_url: str, token: str) -> dict[str, object]: + return await self._get_json(base_url, token, "/api/") + + async def get_config(self, base_url: str, token: str) -> dict[str, object]: + return await self._get_json(base_url, token, "/api/config") + + async def list_entities(self, base_url: str, token: str) -> list[dict[str, object]]: + payload = await self._get_json(base_url, token, "/api/states") + if not isinstance(payload, list): + return [] + return [item for item in payload if isinstance(item, dict)] + + async def call_service( + self, + base_url: str, + token: str, + domain: str, + service: str, + data: dict[str, object], + ) -> dict[str, object]: + async with httpx.AsyncClient(timeout=self.timeout_s) as client: + response = await client.post( + f"{base_url.rstrip('/')}/api/services/{domain}/{service}", + headers=self._headers(token), + json=data, + ) + response.raise_for_status() + payload = response.json() + return payload if isinstance(payload, dict) else {"result": payload} + + async def _get_json(self, base_url: str, token: str, path: str) -> dict[str, object] | list[object]: + async with httpx.AsyncClient(timeout=self.timeout_s) as client: + response = await client.get( + f"{base_url.rstrip('/')}{path}", + headers=self._headers(token), + ) + response.raise_for_status() + payload = response.json() + if isinstance(payload, (dict, list)): + return payload + return {} + + def _headers(self, token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + +@dataclass(slots=True) +class MappingRuntime: + last_dmx_values: list[int] + last_sent_raw_values: list[int] | None = None + last_sent_summary: str | None = None + last_success_at: datetime | None = None + last_error: str | None = None + last_error_at: datetime | None = None + last_service: str | None = None + in_flight: bool = False + pending_raw_values: list[int] | None = None + resync_required: bool = False + switch_state: bool | None = None + + +class HomeAssistantService: + def __init__( + self, + engine: DmxEngine, + session_factory: async_sessionmaker[AsyncSession] = SessionLocal, + adapter: HomeAssistantAdapter | None = None, + ) -> None: + self.engine = engine + self._session_factory = session_factory + self._adapter = adapter or HttpxHomeAssistantAdapter() + self._config = self._default_config() + self._token = "" + self._mappings: list[dict[str, object]] = [] + self._entities_by_id: dict[str, dict[str, object]] = {} + self._mapping_runtime: dict[int, MappingRuntime] = {} + self._mapping_tasks: dict[int, asyncio.Task[None]] = {} + self._running = False + self._task: asyncio.Task[None] | None = None + self._dispatch_count = 0 + self._error_count = 0 + self._last_error: str | None = None + self._last_successful_call_at: datetime | None = None + self._last_connection_success_at: datetime | None = None + self._last_connection_error: str | None = None + self._ha_version: str | None = None + self._auth_ok = False + self._reachable = False + + async def startup(self) -> None: + await self._load() + self._running = True + self._task = asyncio.create_task(self._loop(), name="tuxdmx-home-assistant-bridge") + + async def shutdown(self) -> None: + self._running = False + for task in list(self._mapping_tasks.values()): + task.cancel() + for task in list(self._mapping_tasks.values()): + try: + await task + except asyncio.CancelledError: + pass + self._mapping_tasks.clear() + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def get_config(self) -> dict[str, object]: + return { + **self._config, + "has_token": bool(self._token), + "token_mask": "********" if self._token else "", + "mapping_count": len(self._mappings), + "dispatch_count": self._dispatch_count, + "error_count": self._error_count, + "last_error": self._last_error, + "last_successful_call_at": self._iso(self._last_successful_call_at), + "last_connection_success_at": self._iso(self._last_connection_success_at), + "last_connection_error": self._last_connection_error, + "ha_version": self._ha_version, + "auth_ok": self._auth_ok, + "reachable": self._reachable, + } + + async def save_config(self, payload: dict[str, object]) -> dict[str, object]: + normalized = self._normalize_config(payload) + incoming_token = str(payload.get("token", "") or "").strip() + if incoming_token: + self._token = incoming_token + self._config = normalized + await self._persist_setting( + HA_CONFIG_KEY, + { + **normalized, + "token": self._token, + }, + ) + return await self.get_config() + + async def test_connection(self) -> dict[str, object]: + base_url = str(self._config["base_url"]) + if not base_url or not self._token: + self._reachable = False + self._auth_ok = False + self._last_connection_error = "Base URL eller token mangler." + return { + "reachable": False, + "auth_ok": False, + "ha_version": self._ha_version, + "last_error": self._last_connection_error, + "last_successful_call_at": self._iso(self._last_successful_call_at), + "last_connection_success_at": self._iso(self._last_connection_success_at), + } + + try: + await self._adapter.get_api_root(base_url, self._token) + config = await self._adapter.get_config(base_url, self._token) + except httpx.HTTPStatusError as exc: + self._reachable = True + self._auth_ok = exc.response.status_code != 401 + self._last_connection_error = self._summarize_exception(exc) + return { + "reachable": self._reachable, + "auth_ok": False, + "ha_version": self._ha_version, + "last_error": self._last_connection_error, + "last_successful_call_at": self._iso(self._last_successful_call_at), + "last_connection_success_at": self._iso(self._last_connection_success_at), + } + except Exception as exc: + self._reachable = False + self._auth_ok = False + self._last_connection_error = self._summarize_exception(exc) + return { + "reachable": False, + "auth_ok": False, + "ha_version": self._ha_version, + "last_error": self._last_connection_error, + "last_successful_call_at": self._iso(self._last_successful_call_at), + "last_connection_success_at": self._iso(self._last_connection_success_at), + } + + self._reachable = True + self._auth_ok = True + self._last_connection_error = None + self._last_connection_success_at = datetime.now(UTC) + self._ha_version = str(config.get("version", "") or self._ha_version or "") + return { + "reachable": True, + "auth_ok": True, + "ha_version": self._ha_version, + "last_error": None, + "last_successful_call_at": self._iso(self._last_successful_call_at), + "last_connection_success_at": self._iso(self._last_connection_success_at), + } + + async def list_entities(self) -> dict[str, object]: + if not self._config["base_url"] or not self._token: + return {"items": []} + entities = await self._adapter.list_entities(str(self._config["base_url"]), self._token) + items: list[dict[str, object]] = [] + self._entities_by_id.clear() + for entity in entities: + normalized = self._normalize_entity(entity) + if normalized is None: + continue + self._entities_by_id[str(normalized["entity_id"])] = normalized + items.append(normalized) + items.sort(key=lambda item: (str(item["domain"]), str(item["friendly_name"]), str(item["entity_id"]))) + self._last_connection_success_at = datetime.now(UTC) + self._reachable = True + self._auth_ok = True + return {"items": items} + + async def list_mappings(self) -> dict[str, object]: + return {"items": [self._serialize_mapping(mapping) for mapping in self._mappings]} + + async def create_mapping(self, payload: dict[str, object]) -> dict[str, object]: + mapping = self._normalize_mapping(payload) + mapping["id"] = self._next_mapping_id() + self._mappings.append(mapping) + self._ensure_runtime(int(mapping["id"]), self._channel_span(mapping)) + await self._persist_mappings() + return self._serialize_mapping(mapping) + + async def update_mapping(self, mapping_id: int, payload: dict[str, object]) -> dict[str, object]: + mapping = self._find_mapping(mapping_id) + updated = self._normalize_mapping({**mapping, **payload, "id": mapping_id}) + updated["id"] = mapping_id + index = next(index for index, item in enumerate(self._mappings) if int(item["id"]) == mapping_id) + self._mappings[index] = updated + runtime = self._ensure_runtime(mapping_id, self._channel_span(updated)) + runtime.resync_required = True + await self._persist_mappings() + return self._serialize_mapping(updated) + + async def delete_mapping(self, mapping_id: int) -> dict[str, object]: + mapping = self._find_mapping(mapping_id) + task = self._mapping_tasks.pop(mapping_id, None) + if task is not None: + task.cancel() + self._mappings = [item for item in self._mappings if int(item["id"]) != mapping_id] + self._mapping_runtime.pop(mapping_id, None) + await self._persist_mappings() + return {"deleted": mapping_id, "entity_id": mapping["entity_id"]} + + async def test_mapping(self, mapping_id: int) -> dict[str, object]: + mapping = self._find_mapping(mapping_id) + raw_values = self._build_test_values(mapping) + runtime = self._ensure_runtime(mapping_id, self._channel_span(mapping)) + result = await self._dispatch_mapping(mapping, raw_values, runtime, force=True) + runtime.resync_required = True + return { + "status": "sent" if result else "skipped", + "mapping_id": mapping_id, + "entity_id": mapping["entity_id"], + } + + async def dispatch_once(self) -> None: + await self._dispatch_enabled_mappings() + await self._drain_mapping_tasks() + + async def _loop(self) -> None: + try: + while self._running: + await self._dispatch_enabled_mappings() + await asyncio.sleep(0.05) + except asyncio.CancelledError: + raise + + async def _dispatch_enabled_mappings(self) -> None: + if not bool(self._config["enabled"]): + return + if not self._config["base_url"] or not self._token: + return + self._prune_orphaned_runtimes() + for mapping in self._mappings: + mapping_id = int(mapping["id"]) + runtime = self._ensure_runtime(mapping_id, self._channel_span(mapping)) + frame = self.engine.get_frame(int(mapping["universe"])) + raw_values = self._read_raw_values(mapping, frame.values) + previous_observed = runtime.last_dmx_values.copy() + runtime.last_dmx_values = raw_values.copy() + if not bool(mapping["enabled"]): + continue + if runtime.in_flight: + runtime.pending_raw_values = raw_values.copy() + continue + if runtime.resync_required: + runtime.resync_required = False + self._schedule_dispatch(mapping, runtime, raw_values, force=True) + continue + if not self._should_dispatch(mapping, runtime, previous_observed, raw_values): + continue + self._schedule_dispatch(mapping, runtime, raw_values) + + def _schedule_dispatch( + self, + mapping: dict[str, object], + runtime: MappingRuntime, + raw_values: list[int], + *, + force: bool = False, + ) -> None: + mapping_id = int(mapping["id"]) + runtime.in_flight = True + runtime.pending_raw_values = None + task = asyncio.create_task( + self._dispatch_task(mapping_id, raw_values.copy(), force=force), + name=f"tuxdmx-ha-mapping-{mapping_id}", + ) + self._mapping_tasks[mapping_id] = task + + async def _dispatch_task(self, mapping_id: int, raw_values: list[int], *, force: bool = False) -> None: + try: + mapping = self._find_mapping(mapping_id) + except LookupError: + return + runtime = self._ensure_runtime(mapping_id, self._channel_span(mapping)) + try: + await self._dispatch_mapping(mapping, raw_values, runtime, force=force) + finally: + runtime.in_flight = False + self._mapping_tasks.pop(mapping_id, None) + pending = runtime.pending_raw_values.copy() if runtime.pending_raw_values is not None else None + runtime.pending_raw_values = None + if pending is not None and bool(mapping.get("enabled", True)): + if runtime.resync_required: + runtime.resync_required = False + self._schedule_dispatch(mapping, runtime, pending, force=True) + elif runtime.last_sent_raw_values != pending: + self._schedule_dispatch(mapping, runtime, pending) + + async def _dispatch_mapping( + self, + mapping: dict[str, object], + raw_values: list[int], + runtime: MappingRuntime, + *, + force: bool = False, + ) -> bool: + call = self._build_service_call(mapping, raw_values, runtime, force=force) + if call is None: + return False + domain, service, data, summary = call + try: + await self._adapter.call_service(str(self._config["base_url"]), self._token, domain, service, data) + except Exception as exc: + message = self._summarize_exception(exc) + runtime.last_error = message + runtime.last_error_at = datetime.now(UTC) + self._error_count += 1 + self._last_error = message + self._last_connection_error = message + self._reachable = not isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout)) + if isinstance(exc, httpx.HTTPStatusError): + self._auth_ok = exc.response.status_code != 401 + logger.warning("Home Assistant dispatch failed: %s", message) + return False + + runtime.last_error = None + runtime.last_service = f"{domain}.{service}" + runtime.last_sent_summary = summary + runtime.last_success_at = datetime.now(UTC) + runtime.last_sent_raw_values = raw_values.copy() + if mapping["fixture_type"] == "switch": + normalized = self._normalized_value(raw_values[0], mapping) + if normalized >= SWITCH_ON_THRESHOLD: + runtime.switch_state = True + elif normalized <= SWITCH_OFF_THRESHOLD: + runtime.switch_state = False + self._dispatch_count += 1 + self._last_error = None + self._last_successful_call_at = runtime.last_success_at + self._last_connection_success_at = runtime.last_success_at + self._last_connection_error = None + self._reachable = True + self._auth_ok = True + return True + + def _should_dispatch( + self, + mapping: dict[str, object], + runtime: MappingRuntime, + previous_observed: list[int], + raw_values: list[int], + ) -> bool: + fixture_type = str(mapping["fixture_type"]) + if fixture_type in {"scene", "automation"}: + previous = previous_observed[0] if previous_observed else 0 + current = raw_values[0] if raw_values else 0 + return previous < EDGE_TRIGGER_THRESHOLD <= current + + if fixture_type == "switch": + current = self._normalized_value(raw_values[0], mapping) + if runtime.switch_state is None: + if current >= SWITCH_ON_THRESHOLD: + return True + if current <= SWITCH_OFF_THRESHOLD: + return True + return False + if not runtime.switch_state and current >= SWITCH_ON_THRESHOLD: + return True + if runtime.switch_state and current <= SWITCH_OFF_THRESHOLD: + return True + return False + + previous_sent = runtime.last_sent_raw_values + if previous_sent is None: + return True + deadband = max(0, int(mapping["deadband"])) + if max(abs(current - old) for current, old in zip(raw_values, previous_sent, strict=False)) < deadband: + return False + rate_limit_hz = max(0.1, float(mapping["rate_limit_hz"])) + interval = 1.0 / rate_limit_hz + if runtime.last_success_at is None: + return True + return (monotonic() - self._datetime_to_monotonic_reference(runtime.last_success_at)) >= interval + + def _datetime_to_monotonic_reference(self, value: datetime) -> float: + delta = datetime.now(UTC) - value + return monotonic() - max(0.0, delta.total_seconds()) + + def _build_service_call( + self, + mapping: dict[str, object], + raw_values: list[int], + runtime: MappingRuntime, + *, + force: bool = False, + ) -> tuple[str, str, dict[str, object], str] | None: + fixture_type = str(mapping["fixture_type"]) + entity_id = str(mapping["entity_id"]) + transition = max(0.0, int(mapping["fade_ms"]) / 1000) + entity_domain = self._entity_domain(entity_id) + + if fixture_type == "switch": + normalized = self._normalized_value(raw_values[0], mapping) + if entity_domain not in ON_OFF_ENTITY_DOMAINS: + runtime.last_error = f"Entity {entity_id} understoetter ikke on/off-routing for fixturetype switch." + runtime.last_error_at = datetime.now(UTC) + return None + if normalized >= SWITCH_ON_THRESHOLD or force: + return (entity_domain, "turn_on", {"entity_id": entity_id}, f"{entity_domain}.turn_on {entity_id}") + if normalized <= SWITCH_OFF_THRESHOLD: + return (entity_domain, "turn_off", {"entity_id": entity_id}, f"{entity_domain}.turn_off {entity_id}") + return None + + if fixture_type == "scene": + if not force and self._normalized_value(raw_values[0], mapping) < EDGE_TRIGGER_THRESHOLD: + return None + if entity_domain != "scene": + runtime.last_error = f"Entity {entity_id} er ikke en scene." + runtime.last_error_at = datetime.now(UTC) + return None + return ("scene", "turn_on", {"entity_id": entity_id}, f"scene.turn_on {entity_id}") + + if fixture_type == "automation": + if not force and self._normalized_value(raw_values[0], mapping) < EDGE_TRIGGER_THRESHOLD: + return None + if entity_domain != "automation": + runtime.last_error = f"Entity {entity_id} er ikke en automation." + runtime.last_error_at = datetime.now(UTC) + return None + return ("automation", "trigger", {"entity_id": entity_id}, f"automation.trigger {entity_id}") + + capabilities = self._entities_by_id.get(entity_id) + if capabilities is None: + runtime.last_error = "Entity-capabilities mangler. Hent HA-enheder eller test forbindelsen først." + runtime.last_error_at = datetime.now(UTC) + return None + if entity_domain != "light": + runtime.last_error = f"Entity {entity_id} understoetter ikke fixturetype {fixture_type}." + runtime.last_error_at = datetime.now(UTC) + return None + + if fixture_type == "dimmer": + brightness = self._normalized_value(raw_values[0], mapping) + if brightness <= 0 and not force: + return ("light", "turn_off", {"entity_id": entity_id, "transition": transition}, f"light.turn_off {entity_id}") + payload: dict[str, object] = {"entity_id": entity_id, "transition": transition} + if bool(capabilities.get("supports_brightness", False)): + payload["brightness"] = max(1, brightness) + return ("light", "turn_on", payload, f"light.turn_on {entity_id} brightness={payload.get('brightness', 'on')}") + + if fixture_type == "rgb": + return self._build_rgb_call(mapping, raw_values, capabilities, transition, include_white=False, force=force) + if fixture_type == "rgbw": + return self._build_rgb_call(mapping, raw_values, capabilities, transition, include_white=True, force=force) + if fixture_type == "cct": + return self._build_cct_call(mapping, raw_values, capabilities, transition, force=force) + return None + + def _build_rgb_call( + self, + mapping: dict[str, object], + raw_values: list[int], + capabilities: dict[str, object], + transition: float, + *, + include_white: bool, + force: bool, + ) -> tuple[str, str, dict[str, object], str] | None: + entity_id = str(mapping["entity_id"]) + has_master = bool(mapping["master_dimmer"]) + offset = 1 if has_master else 0 + master = self._normalized_value(raw_values[0], mapping) if has_master else 255 + colors = [self._normalized_value(value, mapping) for value in raw_values[offset : offset + 3]] + white = self._normalized_value(raw_values[offset + 3], mapping) if include_white else 0 + scaled_colors = [int(round(color * (master / 255))) for color in colors] + scaled_white = int(round(white * (master / 255))) if include_white else 0 + brightness = master if has_master else max(scaled_colors + ([scaled_white] if include_white else [0])) + if brightness <= 0 and not force: + return ("light", "turn_off", {"entity_id": entity_id, "transition": transition}, f"light.turn_off {entity_id}") + + payload: dict[str, object] = {"entity_id": entity_id, "transition": transition} + if bool(capabilities.get("supports_brightness", False)): + payload["brightness"] = max(1, brightness) + + if include_white: + if bool(capabilities.get("supports_rgbw", False)): + payload["rgbw_color"] = scaled_colors + [scaled_white] + return ( + "light", + "turn_on", + payload, + f"light.turn_on {entity_id} rgbw={payload['rgbw_color']} brightness={payload.get('brightness', 'on')}", + ) + if bool(capabilities.get("supports_rgbww", False)): + payload["rgbww_color"] = scaled_colors + [scaled_white, 0] + return ( + "light", + "turn_on", + payload, + f"light.turn_on {entity_id} rgbww={payload['rgbww_color']} brightness={payload.get('brightness', 'on')}", + ) + return None + + if bool(capabilities.get("supports_rgb", False)): + payload["rgb_color"] = scaled_colors + return ( + "light", + "turn_on", + payload, + f"light.turn_on {entity_id} rgb={payload['rgb_color']} brightness={payload.get('brightness', 'on')}", + ) + if bool(capabilities.get("supports_rgbw", False)): + payload["rgbw_color"] = scaled_colors + [0] + return ( + "light", + "turn_on", + payload, + f"light.turn_on {entity_id} rgbw={payload['rgbw_color']} brightness={payload.get('brightness', 'on')}", + ) + if bool(capabilities.get("supports_rgbww", False)): + payload["rgbww_color"] = scaled_colors + [0, 0] + return ( + "light", + "turn_on", + payload, + f"light.turn_on {entity_id} rgbww={payload['rgbww_color']} brightness={payload.get('brightness', 'on')}", + ) + return None + + def _build_cct_call( + self, + mapping: dict[str, object], + raw_values: list[int], + capabilities: dict[str, object], + transition: float, + *, + force: bool, + ) -> tuple[str, str, dict[str, object], str] | None: + if not bool(capabilities.get("supports_color_temp_kelvin", False)): + return None + entity_id = str(mapping["entity_id"]) + has_master = bool(mapping["master_dimmer"]) + offset = 1 if has_master else 0 + master = self._normalized_value(raw_values[0], mapping) if has_master else 255 + warm = self._normalized_value(raw_values[offset], mapping) + cold = self._normalized_value(raw_values[offset + 1], mapping) + brightness = master if has_master else max(warm, cold) + if brightness <= 0 and not force: + return ("light", "turn_off", {"entity_id": entity_id, "transition": transition}, f"light.turn_off {entity_id}") + total = max(1, warm + cold) + cool_ratio = cold / total + kelvin = int(round(2200 + (cool_ratio * 4300))) + payload: dict[str, object] = { + "entity_id": entity_id, + "color_temp_kelvin": kelvin, + "transition": transition, + } + if bool(capabilities.get("supports_brightness", False)): + payload["brightness"] = max(1, brightness) + return ( + "light", + "turn_on", + payload, + f"light.turn_on {entity_id} kelvin={kelvin} brightness={payload.get('brightness', 'on')}", + ) + + def _build_test_values(self, mapping: dict[str, object]) -> list[int]: + fixture_type = str(mapping["fixture_type"]) + if fixture_type in {"scene", "automation", "switch"}: + return [255] + if fixture_type == "dimmer": + return [255] + if fixture_type == "rgb": + return [255, 255, 80, 80] if bool(mapping["master_dimmer"]) else [255, 80, 80] + if fixture_type == "rgbw": + return [255, 255, 80, 80, 40] if bool(mapping["master_dimmer"]) else [255, 80, 80, 40] + if fixture_type == "cct": + return [255, 255, 120] if bool(mapping["master_dimmer"]) else [255, 120] + return [255] + + def _read_raw_values(self, mapping: dict[str, object], values: list[int]) -> list[int]: + start_address = int(mapping["start_address"]) + span = self._channel_span(mapping) + if start_address < 1 or start_address + span - 1 > 512: + return [0] * span + return values[start_address - 1 : start_address - 1 + span] + + def _channel_span(self, mapping: dict[str, object]) -> int: + fixture_type = str(mapping["fixture_type"]) + has_master = bool(mapping["master_dimmer"]) + if fixture_type in {"dimmer", "switch", "scene", "automation"}: + return 1 + if fixture_type == "rgb": + return 4 if has_master else 3 + if fixture_type == "rgbw": + return 5 if has_master else 4 + if fixture_type == "cct": + return 3 if has_master else 2 + return 1 + + def _normalized_value(self, raw_value: int, mapping: dict[str, object]) -> int: + clamped = max(0, min(255, int(raw_value))) + if bool(mapping["invert_channel"]): + clamped = 255 - clamped + minimum = max(0, min(255, int(mapping["min_value"]))) + maximum = max(minimum, min(255, int(mapping["max_value"]))) + return int(round(minimum + ((clamped / 255) * (maximum - minimum)))) + + async def _load(self) -> None: + async with self._session_factory() as session: + config_setting = await session.get(Setting, HA_CONFIG_KEY) + mappings_setting = await session.get(Setting, HA_MAPPINGS_KEY) + if config_setting is not None and isinstance(config_setting.value, dict): + raw_config = dict(config_setting.value) + self._token = str(raw_config.get("token", "") or "").strip() + self._config = self._normalize_config(raw_config) + if mappings_setting is not None and isinstance(mappings_setting.value, dict): + items = mappings_setting.value.get("items", []) + if isinstance(items, list): + self._mappings = [self._normalize_mapping(item) for item in items if isinstance(item, dict)] + for mapping in self._mappings: + self._ensure_runtime(int(mapping["id"]), self._channel_span(mapping)) + + async def _persist_setting(self, key: str, value: dict[str, object]) -> None: + async with self._session_factory() as session: + setting = await session.get(Setting, key) + if setting is None: + setting = Setting(key=key, value=value, updated_at=datetime.now(UTC)) + session.add(setting) + else: + setting.value = value + setting.updated_at = datetime.now(UTC) + await session.commit() + + async def _persist_mappings(self) -> None: + await self._persist_setting(HA_MAPPINGS_KEY, {"items": self._mappings}) + + def _default_config(self) -> dict[str, object]: + return { + "enabled": False, + "base_url": "", + "default_universe": 10, + } + + def _normalize_config(self, payload: dict[str, object]) -> dict[str, object]: + return { + "enabled": bool(payload.get("enabled", False)), + "base_url": str(payload.get("base_url", "") or "").strip(), + "default_universe": max(1, min(63999, int(payload.get("default_universe", 10) or 10))), + } + + def _normalize_mapping(self, payload: dict[str, object]) -> dict[str, object]: + fixture_type = str(payload.get("fixture_type", "dimmer")).lower() + if fixture_type not in {"dimmer", "rgb", "rgbw", "cct", "switch", "scene", "automation"}: + fixture_type = "dimmer" + mapping = { + "id": int(payload.get("id", 0) or 0), + "name": str(payload.get("name", "") or "").strip() or str(payload.get("entity_id", "") or "").strip(), + "universe": max(1, min(63999, int(payload.get("universe", self._config["default_universe"]) or self._config["default_universe"]))), + "start_address": max(1, min(512, int(payload.get("start_address", 1) or 1))), + "fixture_type": fixture_type, + "entity_id": str(payload.get("entity_id", "") or "").strip(), + "rate_limit_hz": max(0.1, min(30.0, float(payload.get("rate_limit_hz", 5) or 5))), + "deadband": max(0, min(255, int(payload.get("deadband", 2) or 2))), + "fade_ms": max(0, min(10000, int(payload.get("fade_ms", 0) or 0))), + "invert_channel": bool(payload.get("invert_channel", False)), + "min_value": max(0, min(255, int(payload.get("min_value", 0) or 0))), + "max_value": max(0, min(255, int(payload.get("max_value", 255) or 255))), + "enabled": bool(payload.get("enabled", True)), + "master_dimmer": bool(payload.get("master_dimmer", fixture_type in {"rgb", "rgbw", "cct"})), + } + if mapping["max_value"] < mapping["min_value"]: + mapping["max_value"] = mapping["min_value"] + if not mapping["name"]: + mapping["name"] = mapping["entity_id"] or f"{fixture_type}-{mapping['universe']}-{mapping['start_address']}" + return mapping + + def _normalize_entity(self, payload: dict[str, object]) -> dict[str, object] | None: + entity_id = str(payload.get("entity_id", "") or "").strip() + if "." not in entity_id: + return None + domain = entity_id.split(".", 1)[0] + if domain not in {"light", "switch", "input_boolean", "scene", "automation"}: + return None + attributes = payload.get("attributes", {}) + attr_map = attributes if isinstance(attributes, dict) else {} + supported_modes = attr_map.get("supported_color_modes", []) + modes = {str(mode).lower() for mode in supported_modes if isinstance(mode, str)} + color_mode = str(attr_map.get("color_mode", "") or "").lower() + if color_mode: + modes.add(color_mode) + friendly_name = str(attr_map.get("friendly_name", "") or "") + supports_brightness = domain == "light" and any(mode not in {"onoff"} for mode in modes) + supports_rgb = domain == "light" and any(mode in {"rgb", "hs", "xy"} for mode in modes) + supports_rgbw = domain == "light" and "rgbw" in modes + supports_rgbww = domain == "light" and "rgbww" in modes + supports_color_temp_kelvin = domain == "light" and "color_temp" in modes + return { + "entity_id": entity_id, + "domain": domain, + "friendly_name": friendly_name, + "state": str(payload.get("state", "")), + "supports_brightness": supports_brightness, + "supports_rgb": supports_rgb, + "supports_rgbw": supports_rgbw, + "supports_rgbww": supports_rgbww, + "supports_color_temp_kelvin": supports_color_temp_kelvin, + } + + def _entity_domain(self, entity_id: str) -> str: + if "." not in entity_id: + return "" + return entity_id.split(".", 1)[0].strip().lower() + + def _serialize_mapping(self, mapping: dict[str, object]) -> dict[str, object]: + runtime = self._ensure_runtime(int(mapping["id"]), self._channel_span(mapping)) + status = "disabled" + if bool(mapping["enabled"]): + status = "sending" if runtime.in_flight else "active" + if runtime.last_error: + status = "error" + return { + **mapping, + "channel_span": self._channel_span(mapping), + "status": status, + "last_dmx_values": runtime.last_dmx_values, + "last_sent_summary": runtime.last_sent_summary, + "last_service": runtime.last_service, + "last_success_at": self._iso(runtime.last_success_at), + "last_error": runtime.last_error, + "last_error_at": self._iso(runtime.last_error_at), + "in_flight": runtime.in_flight, + } + + def _next_mapping_id(self) -> int: + return max((int(mapping["id"]) for mapping in self._mappings), default=0) + 1 + + def _find_mapping(self, mapping_id: int) -> dict[str, object]: + for mapping in self._mappings: + if int(mapping["id"]) == mapping_id: + return mapping + raise LookupError("Home Assistant mapping not found") + + def _ensure_runtime(self, mapping_id: int, span: int) -> MappingRuntime: + runtime = self._mapping_runtime.get(mapping_id) + if runtime is None: + runtime = MappingRuntime(last_dmx_values=[0] * span) + self._mapping_runtime[mapping_id] = runtime + return runtime + if len(runtime.last_dmx_values) != span: + runtime.last_dmx_values = [0] * span + runtime.last_sent_raw_values = None + runtime.pending_raw_values = None + return runtime + + def _prune_orphaned_runtimes(self) -> None: + active_ids = {int(mapping["id"]) for mapping in self._mappings} + for mapping_id in list(self._mapping_runtime): + if mapping_id not in active_ids: + self._mapping_runtime.pop(mapping_id, None) + + async def _drain_mapping_tasks(self) -> None: + while self._mapping_tasks: + tasks = list(self._mapping_tasks.values()) + await asyncio.gather(*tasks, return_exceptions=True) + + def _iso(self, value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + def _summarize_exception(self, exc: Exception) -> str: + if isinstance(exc, httpx.HTTPStatusError): + return f"HTTP {exc.response.status_code} fra Home Assistant" + if isinstance(exc, (httpx.ConnectTimeout, httpx.ReadTimeout)): + return "Timeout ved kald til Home Assistant" + if isinstance(exc, httpx.ConnectError): + return "Home Assistant kunne ikke kontaktes" + return str(exc) or exc.__class__.__name__ diff --git a/backend/app/live/__init__.py b/backend/app/live/__init__.py new file mode 100644 index 0000000..42ebd04 --- /dev/null +++ b/backend/app/live/__init__.py @@ -0,0 +1,3 @@ +from app.live.service import LiveDeskService + +__all__ = ["LiveDeskService"] diff --git a/backend/app/live/service.py b/backend/app/live/service.py new file mode 100644 index 0000000..1f72eb2 --- /dev/null +++ b/backend/app/live/service.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.database import SessionLocal +from app.dmx.engine import DmxEngine +from app.dmx.frame import FrameLayer +from app.homeassistant.service import HomeAssistantService +from app.patch.service import PatchService + + +class LiveDeskService: + def __init__( + self, + engine: DmxEngine, + home_assistant: HomeAssistantService | None = None, + session_factory: async_sessionmaker[AsyncSession] = SessionLocal, + ) -> None: + self.engine = engine + self.patch = PatchService(session_factory) + self.home_assistant = home_assistant + self._values_by_patch: dict[int, dict[int, int]] = {} + self._values_by_home_assistant_mapping: dict[int, dict[int, int]] = {} + + async def snapshot(self) -> dict[str, object]: + patches = await self.patch.list_instances() + items: list[dict[str, object]] = [] + for patch in patches: + relative_values = self._values_by_patch.get(int(patch["id"]), {}) + channels: list[dict[str, object]] = [] + for channel in patch.get("channels", []): + if not isinstance(channel, dict): + continue + relative_index = int(channel.get("index", 0)) + absolute_channel = int(patch["start_address"]) + relative_index - 1 + channels.append( + { + "index": relative_index, + "absolute_channel": absolute_channel, + "key": str(channel.get("key", f"channel-{relative_index}")), + "display_name": str(channel.get("display_name", f"Channel {relative_index}")), + "precedence": str(channel.get("precedence", "ltp")), + "resolution": int(channel.get("resolution", 8)), + "value": int(relative_values.get(relative_index, 0)), + } + ) + items.append( + { + "id": f"patch:{patch['id']}", + "source_type": "patch", + "source_id": patch["id"], + "patch_id": patch["id"], + "name": patch["name"], + "manufacturer": patch["manufacturer"], + "model": patch["model"], + "mode_key": patch["mode_key"], + "universe": patch["universe"], + "start_address": patch["start_address"], + "end_address": patch["end_address"], + "channel_count": patch["channel_count"], + "entity_id": None, + "status": "active" if patch.get("enabled", True) else "disabled", + "last_sent_summary": None, + "channels": channels, + } + ) + if self.home_assistant is not None: + mappings = (await self.home_assistant.list_mappings())["items"] + for mapping in mappings: + if not isinstance(mapping, dict): + continue + mapping_id = int(mapping["id"]) + relative_values = self._values_by_home_assistant_mapping.get(mapping_id, {}) + channels = [] + for channel in self._build_home_assistant_channels(mapping): + relative_index = int(channel["index"]) + absolute_channel = int(mapping["start_address"]) + relative_index - 1 + channels.append( + { + "index": relative_index, + "absolute_channel": absolute_channel, + "key": str(channel["key"]), + "display_name": str(channel["display_name"]), + "precedence": str(channel["precedence"]), + "resolution": 8, + "value": int(relative_values.get(relative_index, 0)), + } + ) + items.append( + { + "id": f"home_assistant:{mapping_id}", + "source_type": "home_assistant", + "source_id": mapping_id, + "patch_id": None, + "name": mapping["name"], + "manufacturer": "Home Assistant", + "model": mapping["fixture_type"], + "mode_key": f"HA {mapping['fixture_type']}", + "universe": mapping["universe"], + "start_address": mapping["start_address"], + "end_address": int(mapping["start_address"]) + int(mapping["channel_span"]) - 1, + "channel_count": mapping["channel_span"], + "entity_id": mapping["entity_id"], + "status": mapping["status"], + "last_sent_summary": mapping["last_sent_summary"], + "channels": channels, + } + ) + return {"items": items} + + async def set_patch_values(self, patch_id: int, values: dict[int, int]) -> dict[str, object]: + patches = await self.patch.list_instances() + patch = next((item for item in patches if int(item["id"]) == patch_id), None) + if patch is None: + raise LookupError("Patch not found") + + channel_map = { + int(channel["index"]): channel + for channel in patch.get("channels", []) + if isinstance(channel, dict) and "index" in channel + } + sanitized_values: dict[int, int] = {} + absolute_values: dict[int, int] = {} + precedence_map: dict[int, str] = {} + for relative_index, raw_value in values.items(): + if relative_index not in channel_map: + continue + value = max(0, min(255, int(raw_value))) + sanitized_values[relative_index] = value + absolute_channel = int(patch["start_address"]) + relative_index - 1 + absolute_values[absolute_channel] = value + precedence_map[absolute_channel] = str(channel_map[relative_index].get("precedence", "ltp")).lower() + + self._values_by_patch[patch_id] = sanitized_values + self.engine.set_layer( + FrameLayer.from_channel_values( + name=f"mixer:patch:{patch_id}", + priority=90, + values=absolute_values, + precedence_map=precedence_map, + universe=int(patch["universe"]), + ) + ) + return await self.snapshot() + + async def clear_patch(self, patch_id: int) -> dict[str, object]: + self._values_by_patch.pop(patch_id, None) + self.engine.remove_layer(f"mixer:patch:{patch_id}") + return await self.snapshot() + + async def set_home_assistant_mapping_values(self, mapping_id: int, values: dict[int, int]) -> dict[str, object]: + if self.home_assistant is None: + raise LookupError("Home Assistant live mixer is not available") + mappings = (await self.home_assistant.list_mappings())["items"] + mapping = next((item for item in mappings if int(item["id"]) == mapping_id), None) + if mapping is None: + raise LookupError("Home Assistant mapping not found") + + channel_map = { + int(channel["index"]): channel for channel in self._build_home_assistant_channels(mapping) + } + sanitized_values: dict[int, int] = {} + absolute_values: dict[int, int] = {} + precedence_map: dict[int, str] = {} + for relative_index, raw_value in values.items(): + if relative_index not in channel_map: + continue + value = max(0, min(255, int(raw_value))) + sanitized_values[relative_index] = value + absolute_channel = int(mapping["start_address"]) + relative_index - 1 + absolute_values[absolute_channel] = value + precedence_map[absolute_channel] = str(channel_map[relative_index].get("precedence", "ltp")).lower() + + self._values_by_home_assistant_mapping[mapping_id] = sanitized_values + self.engine.set_layer( + FrameLayer.from_channel_values( + name=f"mixer:home-assistant:{mapping_id}", + priority=90, + values=absolute_values, + precedence_map=precedence_map, + universe=int(mapping["universe"]), + ) + ) + return await self.snapshot() + + async def clear_home_assistant_mapping(self, mapping_id: int) -> dict[str, object]: + self._values_by_home_assistant_mapping.pop(mapping_id, None) + self.engine.remove_layer(f"mixer:home-assistant:{mapping_id}") + return await self.snapshot() + + def _build_home_assistant_channels(self, mapping: dict[str, object]) -> list[dict[str, object]]: + fixture_type = str(mapping["fixture_type"]) + has_master = bool(mapping.get("master_dimmer", False)) + if fixture_type == "switch": + return [{"index": 1, "key": "Switch", "display_name": "State", "precedence": "ltp"}] + if fixture_type == "scene": + return [{"index": 1, "key": "Scene", "display_name": "Trigger", "precedence": "ltp"}] + if fixture_type == "automation": + return [{"index": 1, "key": "Automation", "display_name": "Trigger", "precedence": "ltp"}] + if fixture_type == "dimmer": + return [{"index": 1, "key": "Dimmer", "display_name": "Dimmer", "precedence": "ltp"}] + + channels: list[dict[str, object]] = [] + next_index = 1 + if has_master: + channels.append({"index": next_index, "key": "Master", "display_name": "Master", "precedence": "ltp"}) + next_index += 1 + if fixture_type == "rgb": + names = ["Red", "Green", "Blue"] + elif fixture_type == "rgbw": + names = ["Red", "Green", "Blue", "White"] + elif fixture_type == "cct": + names = ["Warm", "Cold"] + else: + names = ["Channel"] + for name in names: + channels.append({"index": next_index, "key": name, "display_name": name, "precedence": "ltp"}) + next_index += 1 + return channels diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1b9a5a4 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,40 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app.api.router import api_router +from app.core.config import get_settings +from app.core.database import init_database +from app.core.dependencies import app_state +from app.core.logging import configure_logging +from app.websocket.router import websocket_router + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + configure_logging() + await init_database() + await app_state.startup() + try: + yield + finally: + await app_state.shutdown() + + +settings = get_settings() +app = FastAPI(title=settings.app_name, lifespan=lifespan, docs_url=None, redoc_url=None) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.include_router(api_router, prefix="/api/v1") +app.include_router(websocket_router) + +if settings.frontend_dist.exists(): + app.mount("/", StaticFiles(directory=settings.frontend_dist, html=True), name="frontend") diff --git a/backend/app/midi/__init__.py b/backend/app/midi/__init__.py new file mode 100644 index 0000000..df612c2 --- /dev/null +++ b/backend/app/midi/__init__.py @@ -0,0 +1 @@ +"""MIDI bridge integration services.""" diff --git a/backend/app/midi/service.py b/backend/app/midi/service.py new file mode 100644 index 0000000..a1458cb --- /dev/null +++ b/backend/app/midi/service.py @@ -0,0 +1,834 @@ +from __future__ import annotations + +import hashlib +import hmac +import logging +import secrets +from collections import deque +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from fnmatch import fnmatch +from time import monotonic +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.database import SessionLocal +from app.dmx.engine import DmxEngine +from app.dmx.frame import FrameLayer +from app.effects.service import EffectService +from app.models.entities import MidiBridge, MidiBridgeToken, MidiMapping, Scene +from app.scenes.service import SceneService + +logger = logging.getLogger(__name__) + +MIDI_PROTOCOL_VERSION = 1 +MIDI_SCOPES = ["midi:connect", "midi:events", "midi:heartbeat"] +MIDI_ACTIONS = { + "activate_scene", + "deactivate_scene", + "toggle_scene", + "flash_scene", + "blackout_on", + "blackout_off", + "blackout_toggle", + "set_master_dimmer", + "set_scene_intensity", + "start_effect", + "stop_effect", +} +MIDI_MODES = {"trigger", "toggle", "hold", "flash", "continuous"} +SCENE_ACTIONS = { + "activate_scene", + "deactivate_scene", + "toggle_scene", + "flash_scene", + "set_scene_intensity", +} +EFFECT_ACTIONS = {"start_effect", "stop_effect"} + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +@dataclass(slots=True) +class TokenCacheEntry: + id: int + label: str + token_hash: str + bridge_id: str | None + scopes: list[str] + created_at: datetime + revoked_at: datetime | None + last_used_at: datetime | None + + +@dataclass(slots=True) +class LearnState: + active: bool = False + allow_passthrough: bool = False + expires_at: datetime | None = None + captured_event: dict[str, object] | None = None + + +class MidiService: + def __init__( + self, + engine: DmxEngine, + scenes: SceneService, + effects: EffectService, + session_factory: async_sessionmaker[AsyncSession] = SessionLocal, + ) -> None: + self.engine = engine + self.scenes = scenes + self.effects = effects + self._session_factory = session_factory + self._mappings: list[dict[str, object]] = [] + self._tokens: list[TokenCacheEntry] = [] + self._bridges: dict[str, dict[str, object]] = {} + self._bridge_event_windows: dict[str, deque[float]] = {} + self._bridge_persist_deadline: dict[str, float] = {} + self._toggle_state: dict[int, bool] = {} + self._hold_state: dict[int, bool] = {} + self._learn_state = LearnState() + + async def startup(self) -> None: + await self.reload() + + async def reload(self) -> None: + await self._reload_mappings() + await self._reload_tokens() + await self._reload_bridges() + + async def list_mappings(self) -> dict[str, object]: + return {"items": [dict(item) for item in self._mappings]} + + async def get_mapping(self, mapping_id: int) -> dict[str, object]: + mapping = self._mapping_by_id(mapping_id) + if mapping is None: + raise LookupError("MIDI-mapping ikke fundet") + return dict(mapping) + + async def create_mapping(self, payload: dict[str, object]) -> dict[str, object]: + record = MidiMapping( + name=str(payload["name"]).strip(), + enabled=bool(payload.get("enabled", True)), + bridge_id=self._normalize_optional_text(payload.get("bridge_id")), + device_name=self._normalize_optional_text(payload.get("device_name")), + message_type=str(payload["message_type"]).strip(), + channel=self._optional_int(payload.get("channel")), + number=int(payload["number"]), + action=str(payload["action"]).strip(), + target_type=str(payload.get("target_type") or "scene").strip(), + target_id=self._normalize_optional_text(payload.get("target_id")), + mode=str(payload.get("mode") or "trigger").strip(), + minimum_value=int(payload.get("minimum_value", 0)), + maximum_value=int(payload.get("maximum_value", 127)), + updated_at=utc_now(), + ) + self._validate_mapping_record(record) + + async with self._session_factory() as session: + session.add(record) + await session.commit() + await session.refresh(record) + + await self._reload_mappings() + return self._serialize_mapping(record, None) + + async def update_mapping(self, mapping_id: int, payload: dict[str, object]) -> dict[str, object]: + async with self._session_factory() as session: + record = await session.get(MidiMapping, mapping_id) + if record is None: + raise LookupError("MIDI-mapping ikke fundet") + record.name = str(payload["name"]).strip() + record.enabled = bool(payload.get("enabled", True)) + record.bridge_id = self._normalize_optional_text(payload.get("bridge_id")) + record.device_name = self._normalize_optional_text(payload.get("device_name")) + record.message_type = str(payload["message_type"]).strip() + record.channel = self._optional_int(payload.get("channel")) + record.number = int(payload["number"]) + record.action = str(payload["action"]).strip() + record.target_type = str(payload.get("target_type") or "scene").strip() + record.target_id = self._normalize_optional_text(payload.get("target_id")) + record.mode = str(payload.get("mode") or "trigger").strip() + record.minimum_value = int(payload.get("minimum_value", 0)) + record.maximum_value = int(payload.get("maximum_value", 127)) + record.updated_at = utc_now() + self._validate_mapping_record(record) + await session.commit() + await session.refresh(record) + + await self._reload_mappings() + return self._mapping_by_id(mapping_id) or self._serialize_mapping(record, None) + + async def delete_mapping(self, mapping_id: int) -> dict[str, object]: + async with self._session_factory() as session: + record = await session.get(MidiMapping, mapping_id) + if record is None: + raise LookupError("MIDI-mapping ikke fundet") + await session.delete(record) + await session.commit() + + self._toggle_state.pop(mapping_id, None) + self._hold_state.pop(mapping_id, None) + await self._reload_mappings() + return {"deleted": mapping_id} + + async def create_token(self, payload: dict[str, object]) -> dict[str, object]: + raw_token = f"tuxmidi_{secrets.token_urlsafe(32)}" + record = MidiBridgeToken( + label=str(payload["label"]).strip(), + token_hash=self._hash_token(raw_token), + bridge_id=self._normalize_optional_text(payload.get("bridge_id")), + scopes=self._normalize_scopes(payload.get("scopes")), + created_at=utc_now(), + ) + async with self._session_factory() as session: + session.add(record) + await session.commit() + await session.refresh(record) + + await self._reload_tokens() + serialized = self._serialize_token(record) + serialized["token"] = raw_token + return serialized + + async def list_tokens(self) -> dict[str, object]: + return {"items": [self._serialize_token_entry(entry) for entry in self._tokens]} + + async def revoke_token(self, token_id: int) -> dict[str, object]: + async with self._session_factory() as session: + record = await session.get(MidiBridgeToken, token_id) + if record is None: + raise LookupError("MIDI-token ikke fundet") + record.revoked_at = utc_now() + await session.commit() + + await self._reload_tokens() + return {"revoked": token_id} + + async def list_bridges(self) -> dict[str, object]: + self._expire_bridge_presence() + items = sorted(self._bridges.values(), key=lambda bridge: str(bridge["bridge_id"]).casefold()) + return {"items": [dict(item) for item in items]} + + async def get_learn_state(self) -> dict[str, object]: + self._expire_learn_state() + return self._serialize_learn_state() + + async def start_learn(self, timeout_seconds: int, allow_passthrough: bool) -> dict[str, object]: + self._learn_state = LearnState( + active=True, + allow_passthrough=allow_passthrough, + expires_at=utc_now() + timedelta(seconds=timeout_seconds), + captured_event=None, + ) + return self._serialize_learn_state() + + async def cancel_learn(self) -> dict[str, object]: + self._learn_state.active = False + self._learn_state.expires_at = None + return self._serialize_learn_state() + + async def receive_heartbeat(self, payload: dict[str, object], remote_ip: str | None) -> dict[str, object]: + if int(payload.get("protocol_version", 0)) != MIDI_PROTOCOL_VERSION: + raise ValueError("Ugyldig MIDI protocol_version") + bridge_id = str(payload["bridge_id"]).strip() + bridge = self._touch_bridge( + bridge_id=bridge_id, + device_name=self._normalize_optional_text(payload.get("device")), + remote_ip=remote_ip, + protocol_version=int(payload.get("protocol_version", MIDI_PROTOCOL_VERSION)), + is_event=False, + event=None, + error=None, + ) + await self._persist_bridge(bridge_id, force=True) + return { + "accepted": True, + "bridge_id": bridge_id, + "online": bool(bridge["online"]), + "protocol_version": MIDI_PROTOCOL_VERSION, + } + + async def receive_event(self, payload: dict[str, object], remote_ip: str | None) -> dict[str, object]: + if int(payload.get("protocol_version", 0)) != MIDI_PROTOCOL_VERSION: + raise ValueError("Ugyldig MIDI protocol_version") + bridge_id = str(payload["bridge_id"]).strip() + event = self._normalize_event(payload) + bridge = self._touch_bridge( + bridge_id=bridge_id, + device_name=str(payload.get("device") or "").strip(), + remote_ip=remote_ip, + protocol_version=int(payload.get("protocol_version", MIDI_PROTOCOL_VERSION)), + is_event=True, + event=event, + error=None, + ) + if not self._check_bridge_rate_limit(bridge_id): + bridge["last_error"] = "For mange MIDI-events fra bridgen." + await self._persist_bridge(bridge_id, force=True) + return {"accepted": False, "reason": "rate-limited"} + + if self._learn_state.active and not self._learn_state.allow_passthrough: + self._capture_learn_event(bridge, event) + await self._persist_bridge(bridge_id) + return {"accepted": True, "captured_for_learning": True, "matched": 0} + + matches = [mapping for mapping in self._mappings if self._mapping_matches(mapping, bridge, event)] + executed = 0 + errors: list[str] = [] + for mapping in matches: + try: + if self._learn_state.active and self._learn_state.captured_event is None: + self._capture_learn_event(bridge, event) + performed = await self._dispatch_mapping(mapping, event) + executed += 1 if performed else 0 + except Exception as exc: + error_message = f"Mapping {mapping['id']} fejlede: {exc}" + errors.append(error_message) + bridge["last_error"] = error_message + logger.warning("MIDI mapping failed: %s", error_message) + + await self._persist_bridge(bridge_id, force=bool(errors)) + return { + "accepted": True, + "bridge_id": bridge_id, + "matched": len(matches), + "executed": executed, + "errors": errors, + } + + async def test_mapping(self, mapping_id: int, value: int, message_type: str | None = None) -> dict[str, object]: + mapping = self._mapping_by_id(mapping_id) + if mapping is None: + raise LookupError("MIDI-mapping ikke fundet") + test_event = { + "type": message_type or str(mapping["message_type"]), + "channel": int(mapping["channel"] or 0), + "number": int(mapping["number"]), + "value": value, + } + await self._dispatch_mapping(mapping, test_event) + return { + "status": "sent", + "mapping_id": mapping_id, + "service": mapping["action"], + "value": value, + } + + async def verify_token(self, token: str | None, required_scope: str, bridge_id: str | None = None) -> TokenCacheEntry: + if not token: + raise PermissionError("Mangler Bearer-token") + token_hash = self._hash_token(token) + for entry in self._tokens: + if not hmac.compare_digest(entry.token_hash, token_hash): + continue + if entry.revoked_at is not None: + break + if required_scope not in entry.scopes: + break + if entry.bridge_id and bridge_id and entry.bridge_id != bridge_id: + break + await self._mark_token_used(entry.id) + return entry + raise PermissionError("Ugyldig eller tilbagekaldt MIDI-token") + + async def _dispatch_mapping(self, mapping: dict[str, object], event: dict[str, object]) -> bool: + action = str(mapping["action"]) + mode = str(mapping["mode"]) + target_id = self._normalize_optional_text(mapping.get("target_id")) + value = int(event["value"]) + normalized = self._normalize_value(value, int(mapping["minimum_value"]), int(mapping["maximum_value"])) + message_type = str(event["type"]) + if action == "activate_scene" and target_id: + if mode == "hold": + if message_type == "note_on": + await self.scenes.activate(target_id) + self._hold_state[int(mapping["id"])] = True + return True + if message_type == "note_off": + await self.scenes.release(target_id) + self._hold_state[int(mapping["id"])] = False + return True + return False + if message_type == "note_off": + return False + await self.scenes.activate(target_id) + return True + + if action == "deactivate_scene" and target_id: + if message_type == "note_off" and mode == "hold": + return False + await self.scenes.release(target_id) + return True + + if action == "toggle_scene" and target_id: + if message_type == "note_off": + return False + if self.engine.layers.get(f"scene:{target_id}") is not None: + await self.scenes.release(target_id) + self._toggle_state[int(mapping["id"])] = False + else: + await self.scenes.activate(target_id) + self._toggle_state[int(mapping["id"])] = True + return True + + if action == "flash_scene" and target_id: + layer_name = f"midi:flash:{mapping['id']}:{target_id}" + if message_type == "note_on": + layer = await self._build_scene_layer( + target_id, + layer_name=layer_name, + scale=1.0, + priority_override=90, + ) + self.engine.set_layer(layer) + self._hold_state[int(mapping["id"])] = True + return True + if message_type == "note_off": + self.engine.remove_layer(layer_name) + self._hold_state[int(mapping["id"])] = False + return True + return False + + if action == "blackout_on": + if message_type == "note_off" and mode != "continuous": + return False + self.engine.trigger_blackout() + return True + + if action == "blackout_off": + if message_type == "note_off" and mode != "continuous": + return False + self.engine.release_blackout() + return True + + if action == "blackout_toggle": + if message_type == "note_off": + return False + if self.engine.blackout: + self.engine.release_blackout() + self._toggle_state[int(mapping["id"])] = False + else: + self.engine.trigger_blackout() + self._toggle_state[int(mapping["id"])] = True + return True + + if action == "set_master_dimmer": + self.engine.master = max(0, min(255, int(round(normalized * 255)))) + return True + + if action == "set_scene_intensity" and target_id: + layer_name = f"midi:intensity:{mapping['id']}:{target_id}" + if normalized <= 0: + self.engine.remove_layer(layer_name) + return True + layer = await self._build_scene_layer( + target_id, + layer_name=layer_name, + scale=normalized, + priority_override=80, + ) + self.engine.set_layer(layer) + return True + + if action == "start_effect" and target_id: + if mode == "hold": + if message_type == "note_on": + self.effects.trigger(target_id) + self._hold_state[int(mapping["id"])] = True + return True + if message_type == "note_off": + self.effects.stop(target_id) + self._hold_state[int(mapping["id"])] = False + return True + return False + if mode == "toggle": + if message_type == "note_off": + return False + if target_id in self.effects.active_effects: + self.effects.stop(target_id) + self._toggle_state[int(mapping["id"])] = False + else: + self.effects.trigger(target_id) + self._toggle_state[int(mapping["id"])] = True + return True + if message_type == "note_off": + return False + self.effects.trigger(target_id) + return True + + if action == "stop_effect" and target_id: + if message_type == "note_off" and mode != "continuous": + return False + self.effects.stop(target_id) + return True + + raise ValueError(f"Ukendt eller ikke-understøttet MIDI-action: {action}") + + async def _build_scene_layer( + self, + slug: str, + layer_name: str, + scale: float, + priority_override: int | None = None, + ) -> FrameLayer: + scene, values_by_universe, precedence_by_universe = await self._resolve_scene_layer_data(slug) + scaled_values: dict[int, dict[int, int]] = {} + for universe, values in values_by_universe.items(): + scaled_values[universe] = { + channel: max(0, min(255, int(round(value * scale)))) + for channel, value in values.items() + } + return FrameLayer( + name=layer_name, + priority=priority_override if priority_override is not None else int(scene.priority), + values_by_universe=scaled_values, + precedence_map_by_universe=precedence_by_universe, + ) + + async def _resolve_scene_layer_data( + self, + slug: str, + ) -> tuple[Scene, dict[int, dict[int, int]], dict[int, dict[int, str]]]: + async with self._session_factory() as session: + result = await session.execute(select(Scene).where(Scene.slug == slug).limit(1)) + scene = result.scalar_one_or_none() + if scene is None: + raise LookupError("Scene ikke fundet") + values_by_universe, precedence_by_universe = await self.scenes._resolve_layer_data(scene) + return scene, values_by_universe, precedence_by_universe + + async def _reload_mappings(self) -> None: + async with self._session_factory() as session: + result = await session.execute(select(MidiMapping).order_by(MidiMapping.created_at, MidiMapping.id)) + self._mappings = [self._serialize_mapping(record, None) for record in result.scalars().all()] + + async def _reload_tokens(self) -> None: + async with self._session_factory() as session: + result = await session.execute(select(MidiBridgeToken).order_by(MidiBridgeToken.created_at, MidiBridgeToken.id)) + self._tokens = [ + TokenCacheEntry( + id=record.id, + label=record.label, + token_hash=record.token_hash, + bridge_id=record.bridge_id, + scopes=list(record.scopes or MIDI_SCOPES), + created_at=record.created_at, + revoked_at=record.revoked_at, + last_used_at=record.last_used_at, + ) + for record in result.scalars().all() + ] + + async def _reload_bridges(self) -> None: + async with self._session_factory() as session: + result = await session.execute(select(MidiBridge).order_by(MidiBridge.bridge_id)) + self._bridges = {record.bridge_id: self._serialize_bridge(record) for record in result.scalars().all()} + + async def _mark_token_used(self, token_id: int) -> None: + now = utc_now() + for entry in self._tokens: + if entry.id == token_id: + entry.last_used_at = now + break + async with self._session_factory() as session: + record = await session.get(MidiBridgeToken, token_id) + if record is None: + return + record.last_used_at = now + await session.commit() + + def _mapping_matches(self, mapping: dict[str, object], bridge: dict[str, object], event: dict[str, object]) -> bool: + if not bool(mapping["enabled"]): + return False + mapping_message_type = str(mapping["message_type"]) + event_type = str(event["type"]) + if mapping_message_type != event_type: + if not ( + mapping_message_type == "note_on" + and event_type == "note_off" + and str(mapping["mode"]) in {"hold", "flash"} + ): + return False + if int(mapping["number"]) != int(event["number"]): + return False + channel = mapping.get("channel") + if channel is not None and int(channel) != int(event["channel"]): + return False + if not self._wildcard_match(mapping.get("bridge_id"), bridge.get("bridge_id")): + return False + if not self._wildcard_match(mapping.get("device_name"), bridge.get("device_name")): + return False + return True + + def _touch_bridge( + self, + bridge_id: str, + device_name: str | None, + remote_ip: str | None, + protocol_version: int, + is_event: bool, + event: dict[str, object] | None, + error: str | None, + ) -> dict[str, object]: + now = utc_now() + bridge = self._bridges.get(bridge_id) or { + "bridge_id": bridge_id, + "device_name": device_name or "", + "ip_address": remote_ip, + "protocol_version": protocol_version, + "online": True, + "last_heartbeat_at": None, + "last_event_at": None, + "last_error": None, + "last_event": None, + "updated_at": now.isoformat(), + "created_at": now.isoformat(), + } + bridge["device_name"] = device_name or bridge.get("device_name") or "" + bridge["ip_address"] = remote_ip or bridge.get("ip_address") + bridge["protocol_version"] = protocol_version + bridge["online"] = True + bridge["last_heartbeat_at"] = now.isoformat() + bridge["updated_at"] = now.isoformat() + if is_event: + bridge["last_event_at"] = now.isoformat() + bridge["last_event"] = event + if error is not None: + bridge["last_error"] = error + self._bridges[bridge_id] = bridge + return bridge + + async def _persist_bridge(self, bridge_id: str, force: bool = False) -> None: + now = monotonic() + previous = self._bridge_persist_deadline.get(bridge_id, 0.0) + if not force and now - previous < 2.0: + return + self._bridge_persist_deadline[bridge_id] = now + bridge = self._bridges.get(bridge_id) + if bridge is None: + return + async with self._session_factory() as session: + result = await session.execute(select(MidiBridge).where(MidiBridge.bridge_id == bridge_id).limit(1)) + record = result.scalar_one_or_none() + if record is None: + record = MidiBridge( + bridge_id=bridge_id, + device_name=self._normalize_optional_text(bridge.get("device_name")), + ip_address=self._normalize_optional_text(bridge.get("ip_address")), + protocol_version=int(bridge.get("protocol_version", MIDI_PROTOCOL_VERSION)), + online=bool(bridge.get("online", True)), + created_at=utc_now(), + updated_at=utc_now(), + ) + session.add(record) + record.device_name = self._normalize_optional_text(bridge.get("device_name")) + record.ip_address = self._normalize_optional_text(bridge.get("ip_address")) + record.protocol_version = int(bridge.get("protocol_version", MIDI_PROTOCOL_VERSION)) + record.online = bool(bridge.get("online", True)) + record.last_heartbeat_at = self._parse_dt(bridge.get("last_heartbeat_at")) + record.last_event_at = self._parse_dt(bridge.get("last_event_at")) + record.last_error = self._normalize_optional_text(bridge.get("last_error")) + record.last_event = dict(bridge.get("last_event") or {}) + record.updated_at = utc_now() + await session.commit() + + def _check_bridge_rate_limit(self, bridge_id: str) -> bool: + window = self._bridge_event_windows.setdefault(bridge_id, deque()) + now = monotonic() + while window and now - window[0] > 1.0: + window.popleft() + window.append(now) + return len(window) <= 200 + + def _expire_bridge_presence(self) -> None: + threshold = utc_now() - timedelta(seconds=15) + for bridge in self._bridges.values(): + last_heartbeat = self._parse_dt(bridge.get("last_heartbeat_at")) + bridge["online"] = bool(last_heartbeat and last_heartbeat >= threshold) + + def _expire_learn_state(self) -> None: + if not self._learn_state.active or self._learn_state.expires_at is None: + return + if self._learn_state.expires_at < utc_now(): + self._learn_state.active = False + + def _capture_learn_event(self, bridge: dict[str, object], event: dict[str, object]) -> None: + self._learn_state.captured_event = { + "bridge_id": bridge.get("bridge_id"), + "device": bridge.get("device_name"), + "timestamp": utc_now().isoformat(), + "message": dict(event), + } + self._learn_state.active = False + + def _serialize_learn_state(self) -> dict[str, object]: + return { + "active": self._learn_state.active, + "allow_passthrough": self._learn_state.allow_passthrough, + "expires_at": self._learn_state.expires_at.isoformat() if self._learn_state.expires_at else None, + "captured_event": self._learn_state.captured_event, + } + + def _serialize_mapping(self, record: MidiMapping, runtime: dict[str, object] | None) -> dict[str, object]: + return { + "id": record.id, + "name": record.name, + "enabled": record.enabled, + "bridge_id": record.bridge_id, + "device_name": record.device_name, + "message_type": record.message_type, + "channel": record.channel, + "number": record.number, + "action": record.action, + "target_type": record.target_type, + "target_id": record.target_id, + "mode": record.mode, + "minimum_value": record.minimum_value, + "maximum_value": record.maximum_value, + "created_at": record.created_at.isoformat(), + "updated_at": record.updated_at.isoformat(), + "active": bool(runtime.get("active")) if runtime else self._mapping_is_active(record), + } + + def _serialize_bridge(self, record: MidiBridge) -> dict[str, object]: + return { + "bridge_id": record.bridge_id, + "device_name": record.device_name or "", + "ip_address": record.ip_address, + "protocol_version": record.protocol_version, + "online": record.online, + "last_heartbeat_at": record.last_heartbeat_at.isoformat() if record.last_heartbeat_at else None, + "last_event_at": record.last_event_at.isoformat() if record.last_event_at else None, + "last_error": record.last_error, + "last_event": record.last_event or None, + "updated_at": record.updated_at.isoformat(), + "created_at": record.created_at.isoformat(), + } + + def _serialize_token(self, record: MidiBridgeToken) -> dict[str, object]: + return { + "id": record.id, + "label": record.label, + "bridge_id": record.bridge_id, + "scopes": list(record.scopes or MIDI_SCOPES), + "created_at": record.created_at.isoformat(), + "revoked_at": record.revoked_at.isoformat() if record.revoked_at else None, + "last_used_at": record.last_used_at.isoformat() if record.last_used_at else None, + "token_preview": self._token_preview(record.token_hash), + } + + def _serialize_token_entry(self, entry: TokenCacheEntry) -> dict[str, object]: + return { + "id": entry.id, + "label": entry.label, + "bridge_id": entry.bridge_id, + "scopes": list(entry.scopes), + "created_at": entry.created_at.isoformat(), + "revoked_at": entry.revoked_at.isoformat() if entry.revoked_at else None, + "last_used_at": entry.last_used_at.isoformat() if entry.last_used_at else None, + "token_preview": self._token_preview(entry.token_hash), + } + + def _normalize_event(self, payload: dict[str, object]) -> dict[str, object]: + message = payload.get("message") + if not isinstance(message, dict): + raise ValueError("MIDI payload mangler message") + return { + "type": str(message["type"]), + "channel": int(message["channel"]), + "number": int(message["number"]), + "value": int(message["value"]), + } + + def _mapping_is_active(self, record: MidiMapping) -> bool: + target_id = record.target_id + if record.action in SCENE_ACTIONS and target_id: + if record.action == "set_scene_intensity": + return self.engine.layers.get(f"midi:intensity:{record.id}:{target_id}") is not None + if record.action == "flash_scene": + return self.engine.layers.get(f"midi:flash:{record.id}:{target_id}") is not None + return self.engine.layers.get(f"scene:{target_id}") is not None + if record.action in EFFECT_ACTIONS and target_id: + return target_id in self.effects.active_effects + if record.action.startswith("blackout"): + return self.engine.blackout + if record.action == "set_master_dimmer": + return self.engine.master > 0 + return False + + def _mapping_by_id(self, mapping_id: int) -> dict[str, object] | None: + for mapping in self._mappings: + if int(mapping["id"]) == mapping_id: + return mapping + return None + + def _validate_mapping_record(self, record: MidiMapping) -> None: + if record.action not in MIDI_ACTIONS: + raise ValueError("Ukendt MIDI-action") + if record.mode not in MIDI_MODES: + raise ValueError("Ukendt MIDI-mode") + if record.minimum_value > record.maximum_value: + raise ValueError("minimum_value må ikke være større end maximum_value") + if record.action in SCENE_ACTIONS and not record.target_id: + raise ValueError("Scene-actions kræver target_id") + if record.action in EFFECT_ACTIONS and not record.target_id: + raise ValueError("Effect-actions kræver target_id") + if record.action == "set_scene_intensity" and record.mode != "continuous": + raise ValueError("set_scene_intensity kræver continuous-mode") + if record.action == "set_master_dimmer" and record.mode != "continuous": + raise ValueError("set_master_dimmer kræver continuous-mode") + if record.action == "flash_scene" and record.mode not in {"hold", "flash"}: + raise ValueError("flash_scene kræver hold eller flash-mode") + if record.message_type == "control_change" and record.mode in {"hold", "flash"}: + return + + def _normalize_value(self, raw: int, minimum: int, maximum: int) -> float: + clamped = max(minimum, min(maximum, raw)) + span = max(1, maximum - minimum) + return max(0.0, min(1.0, (clamped - minimum) / span)) + + def _wildcard_match(self, pattern: object, value: object) -> bool: + normalized_pattern = self._normalize_optional_text(pattern) + if not normalized_pattern or normalized_pattern == "*": + return True + normalized_value = self._normalize_optional_text(value) + if not normalized_value: + return False + return fnmatch(normalized_value.casefold(), normalized_pattern.casefold()) + + def _normalize_optional_text(self, value: object) -> str | None: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + def _optional_int(self, value: object) -> int | None: + if value is None or value == "": + return None + return int(value) + + def _normalize_scopes(self, payload: object) -> list[str]: + if not isinstance(payload, list): + return list(MIDI_SCOPES) + scopes = [str(scope).strip() for scope in payload if str(scope).strip()] + return scopes or list(MIDI_SCOPES) + + def _hash_token(self, token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + def _token_preview(self, token_hash: str) -> str: + return f"********{token_hash[-4:]}" + + def _parse_dt(self, value: object) -> datetime | None: + if isinstance(value, datetime): + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + if isinstance(value, str) and value: + parsed = datetime.fromisoformat(value) + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) + return None diff --git a/backend/app/mixitup/__init__.py b/backend/app/mixitup/__init__.py new file mode 100644 index 0000000..6383fd7 --- /dev/null +++ b/backend/app/mixitup/__init__.py @@ -0,0 +1,2 @@ +"""MixItUp integration services.""" + diff --git a/backend/app/mixitup/service.py b/backend/app/mixitup/service.py new file mode 100644 index 0000000..57c8a4a --- /dev/null +++ b/backend/app/mixitup/service.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from collections import deque +from time import monotonic + +from app.dmx.engine import DmxEngine +from app.effects.service import EffectService +from app.models.schemas import TriggerRequest +from app.scenes.service import SceneService + + +class TriggerService: + def __init__( + self, + engine: DmxEngine, + effects: EffectService, + scenes: SceneService, + ) -> None: + self.engine = engine + self.effects = effects + self.scenes = scenes + self.queue: deque[dict[str, object]] = deque(maxlen=100) + self._recent_ids: dict[str, float] = {} + + def enqueue(self, slug: str, payload: TriggerRequest) -> dict[str, object]: + if payload.event_id: + previous = self._recent_ids.get(payload.event_id) + if previous and monotonic() - previous < 30: + return {"accepted": False, "reason": "duplicate-event"} + self._recent_ids[payload.event_id] = monotonic() + item: dict[str, object] = { + "slug": slug, + "payload": payload.model_dump(by_alias=True), + } + self.queue.append(item) + return {"accepted": True, "queue_depth": len(self.queue)} + + def snapshot(self) -> list[dict[str, object]]: + return list(self.queue) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..1805077 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,2 @@ +"""ORM and schema modules.""" + diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..3a60ede --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,6 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + diff --git a/backend/app/models/entities.py b/backend/app/models/entities.py new file mode 100644 index 0000000..0627af2 --- /dev/null +++ b/backend/app/models/entities.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(100), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + role: Mapped[str] = mapped_column(String(30), default="administrator") + disabled: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class SessionRecord(Base): + __tablename__ = "sessions" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, index=True) + csrf_token: Mapped[str] = mapped_column(String(64)) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class ApiToken(Base): + __tablename__ = "api_tokens" + + id: Mapped[int] = mapped_column(primary_key=True) + label: Mapped[str] = mapped_column(String(120)) + token_hash: Mapped[str] = mapped_column(String(255)) + role: Mapped[str] = mapped_column(String(30), default="operator") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class Setting(Base): + __tablename__ = "settings" + + key: Mapped[str] = mapped_column(String(120), primary_key=True) + value: Mapped[dict[str, object]] = mapped_column(JSON, default=dict) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class FixtureSource(Base): + __tablename__ = "fixture_sources" + + id: Mapped[int] = mapped_column(primary_key=True) + manufacturer_key: Mapped[str] = mapped_column(String(100)) + fixture_key: Mapped[str] = mapped_column(String(150)) + schema_ref: Mapped[str] = mapped_column(String(255)) + source_url: Mapped[str] = mapped_column(String(255)) + payload: Mapped[dict[str, object]] = mapped_column(JSON) + payload_hash: Mapped[str] = mapped_column(String(64)) + imported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class FixtureDefinition(Base): + __tablename__ = "fixture_definitions" + + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[str] = mapped_column(String(160), unique=True, index=True) + manufacturer: Mapped[str] = mapped_column(String(120)) + model: Mapped[str] = mapped_column(String(160)) + short_name: Mapped[str | None] = mapped_column(String(120), nullable=True) + categories: Mapped[list[str]] = mapped_column(JSON, default=list) + normalized_data: Mapped[dict[str, object]] = mapped_column(JSON) + source_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class FixtureInstance(Base): + __tablename__ = "fixture_instances" + + id: Mapped[int] = mapped_column(primary_key=True) + universe: Mapped[int] = mapped_column(Integer, default=1) + name: Mapped[str] = mapped_column(String(160)) + definition_id: Mapped[int] = mapped_column(Integer, index=True) + mode_key: Mapped[str] = mapped_column(String(120)) + start_address: Mapped[int] = mapped_column(Integer) + channel_count: Mapped[int] = mapped_column(Integer) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + group_names: Mapped[list[str]] = mapped_column(JSON, default=list) + position: Mapped[dict[str, object]] = mapped_column(JSON, default=dict) + tags: Mapped[list[str]] = mapped_column(JSON, default=list) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class Scene(Base): + __tablename__ = "scenes" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(160)) + slug: Mapped[str] = mapped_column(String(160), unique=True, index=True) + color: Mapped[str] = mapped_column(String(20), default="#00e5ff") + icon: Mapped[str | None] = mapped_column(String(40), nullable=True) + priority: Mapped[int] = mapped_column(Integer, default=10) + fade_in_ms: Mapped[int] = mapped_column(Integer, default=500) + fade_out_ms: Mapped[int] = mapped_column(Integer, default=500) + hold_ms: Mapped[int] = mapped_column(Integer, default=0) + master_limit: Mapped[int] = mapped_column(Integer, default=255) + values: Mapped[list[dict[str, object]]] = mapped_column(JSON, default=list) + targets: Mapped[list[dict[str, object]]] = mapped_column(JSON, default=list) + tags: Mapped[list[str]] = mapped_column(JSON, default=list) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class Effect(Base): + __tablename__ = "effects" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(160)) + slug: Mapped[str] = mapped_column(String(160), unique=True, index=True) + effect_type: Mapped[str] = mapped_column(String(80)) + parameters: Mapped[dict[str, object]] = mapped_column(JSON, default=dict) + priority: Mapped[int] = mapped_column(Integer, default=40) + cooldown_ms: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class TriggerMapping(Base): + __tablename__ = "trigger_mappings" + + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[str] = mapped_column(String(160), unique=True, index=True) + name: Mapped[str] = mapped_column(String(160)) + event_type: Mapped[str] = mapped_column(String(60)) + action_type: Mapped[str] = mapped_column(String(30)) + action_slug: Mapped[str] = mapped_column(String(160)) + config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict) + active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class TriggerEvent(Base): + __tablename__ = "trigger_events" + + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[str] = mapped_column(String(160), index=True) + event_id: Mapped[str | None] = mapped_column(String(160), nullable=True) + status: Mapped[str] = mapped_column(String(30), default="queued") + payload: Mapped[dict[str, object]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class SystemEvent(Base): + __tablename__ = "system_events" + + id: Mapped[int] = mapped_column(primary_key=True) + category: Mapped[str] = mapped_column(String(40)) + level: Mapped[str] = mapped_column(String(20)) + message: Mapped[str] = mapped_column(Text) + payload: Mapped[dict[str, object]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class BackupRecord(Base): + __tablename__ = "backups" + + id: Mapped[int] = mapped_column(primary_key=True) + label: Mapped[str] = mapped_column(String(160)) + path: Mapped[str] = mapped_column(String(255)) + manifest: Mapped[dict[str, object]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class MidiBridge(Base): + __tablename__ = "midi_bridges" + + id: Mapped[int] = mapped_column(primary_key=True) + bridge_id: Mapped[str] = mapped_column(String(160), unique=True, index=True) + device_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True) + protocol_version: Mapped[int] = mapped_column(Integer, default=1) + online: Mapped[bool] = mapped_column(Boolean, default=False) + last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + last_event: Mapped[dict[str, object]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class MidiBridgeToken(Base): + __tablename__ = "midi_bridge_tokens" + + id: Mapped[int] = mapped_column(primary_key=True) + label: Mapped[str] = mapped_column(String(120)) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + bridge_id: Mapped[str | None] = mapped_column(String(160), nullable=True) + scopes: Mapped[list[str]] = mapped_column(JSON, default=list) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class MidiMapping(Base): + __tablename__ = "midi_mappings" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(160)) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + bridge_id: Mapped[str | None] = mapped_column(String(160), nullable=True) + device_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + message_type: Mapped[str] = mapped_column(String(40)) + channel: Mapped[int | None] = mapped_column(Integer, nullable=True) + number: Mapped[int] = mapped_column(Integer) + action: Mapped[str] = mapped_column(String(60)) + target_type: Mapped[str] = mapped_column(String(40)) + target_id: Mapped[str | None] = mapped_column(String(160), nullable=True) + mode: Mapped[str] = mapped_column(String(40), default="trigger") + minimum_value: Mapped[int] = mapped_column(Integer, default=0) + maximum_value: Mapped[int] = mapped_column(Integer, default=127) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000..02a0b23 --- /dev/null +++ b/backend/app/models/schemas.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class HealthResponse(BaseModel): + status: str + simulator_enabled: bool + version: str + + +class ChannelValue(BaseModel): + channel: int = Field(ge=1, le=512) + value: int = Field(ge=0, le=255) + precedence: str = "htp" + source: str = "scene" + + +class FixturePositionPayload(BaseModel): + x: float = Field(default=50.0, ge=0.0, le=100.0) + y: float = Field(default=50.0, ge=0.0, le=100.0) + z: float = Field(default=0.0, ge=0.0, le=100.0) + rotation: float = Field(default=0.0, ge=0.0, le=360.0) + + +class SceneAttributeValue(BaseModel): + attribute: str + value: int = Field(ge=0, le=255) + precedence: str | None = None + + +class SceneTargetPayload(BaseModel): + target_type: Literal["fixture", "group"] + patch_id: int | None = Field(default=None, ge=1) + group_name: str | None = None + values: list[SceneAttributeValue] = Field(default_factory=list) + + +class ScenePayload(BaseModel): + name: str + slug: str + color: str = "#00e5ff" + icon: str | None = None + priority: int = 10 + fade_in_ms: int = 500 + fade_out_ms: int = 500 + hold_ms: int = 0 + master_limit: int = 255 + values: list[ChannelValue] = Field(default_factory=list) + targets: list[SceneTargetPayload] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + + +class EffectPayload(BaseModel): + name: str + slug: str + effect_type: str + parameters: dict[str, object] = Field(default_factory=dict) + priority: int = 40 + cooldown_ms: int = 0 + + +class TriggerRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + event_type: str = Field(default="custom", alias="eventType") + username: str | None = None + display_name: str | None = Field(default=None, alias="displayName") + amount: int | None = None + event_id: str | None = Field(default=None, alias="eventId") + platform: str = "twitch" + metadata: dict[str, object] = Field(default_factory=dict) + + +class TelemetrySnapshot(BaseModel): + uptime_seconds: float + cpu_percent: float + ram_percent: float + temperature_c: float | None = None + queue_depth: int + reconnect_count: int + last_error: str | None = None + last_sent_at: datetime | None = None + + +class PatchPayload(BaseModel): + universe: int = Field(default=1, ge=1, le=63999) + name: str + definition_id: int = Field(ge=1) + mode_key: str + start_address: int = Field(ge=1, le=512) + enabled: bool = True + group_names: list[str] = Field(default_factory=list) + position: FixturePositionPayload = Field(default_factory=FixturePositionPayload) + + +class PatchValidationPayload(BaseModel): + universe: int = Field(default=1, ge=1, le=63999) + definition_id: int = Field(ge=1) + mode_key: str + start_address: int = Field(ge=1, le=512) + enabled: bool = True + exclude_patch_id: int | None = Field(default=None, ge=1) + + +class ManualPatchValuesPayload(BaseModel): + values: dict[int, int] = Field(default_factory=dict) + + +class BpmAudioStartPayload(BaseModel): + device: str = "alsa:auto" + + +class BpmAudioConfigPayload(BaseModel): + preferred_device: str = "alsa:auto" + + +class DmxOutputConfigPayload(BaseModel): + backend: Literal["simulator", "ola", "artnet"] = "simulator" + universe: int = Field(default=1, ge=1, le=63999) + output_port: str | None = None + target_host: str | None = None + + +class HomeAssistantConfigPayload(BaseModel): + enabled: bool = False + base_url: str = "" + token: str | None = None + default_universe: int = Field(default=10, ge=1, le=63999) + + +class HomeAssistantMappingPayload(BaseModel): + name: str + universe: int = Field(default=10, ge=1, le=63999) + start_address: int = Field(ge=1, le=512) + fixture_type: Literal["dimmer", "rgb", "rgbw", "cct", "switch", "scene", "automation"] = "dimmer" + entity_id: str + rate_limit_hz: float = Field(default=5.0, ge=0.1, le=30.0) + deadband: int = Field(default=2, ge=0, le=255) + fade_ms: int = Field(default=0, ge=0, le=10000) + invert_channel: bool = False + min_value: int = Field(default=0, ge=0, le=255) + max_value: int = Field(default=255, ge=0, le=255) + enabled: bool = True + master_dimmer: bool = True + + +class MidiBridgeTokenCreatePayload(BaseModel): + label: str + bridge_id: str | None = None + scopes: list[str] = Field(default_factory=lambda: ["midi:connect", "midi:events", "midi:heartbeat"]) + + +class MidiBridgeTokenResponse(BaseModel): + id: int + label: str + bridge_id: str | None + scopes: list[str] + created_at: datetime + revoked_at: datetime | None = None + last_used_at: datetime | None = None + token_preview: str + + +class MidiMessagePayload(BaseModel): + type: Literal["note_on", "note_off", "control_change", "program_change"] + channel: int = Field(ge=0, le=15) + number: int = Field(ge=0, le=127) + value: int = Field(ge=0, le=127) + + +class MidiBridgeEventPayload(BaseModel): + type: Literal["midi_event"] + protocol_version: int = Field(default=1, ge=1) + bridge_id: str + device: str + timestamp: datetime + message: MidiMessagePayload + + +class MidiBridgeHeartbeatPayload(BaseModel): + type: Literal["heartbeat"] + protocol_version: int = Field(default=1, ge=1) + bridge_id: str + device: str | None = None + timestamp: datetime | None = None + + +class MidiMappingPayload(BaseModel): + name: str + enabled: bool = True + bridge_id: str | None = None + device_name: str | None = None + message_type: Literal["note_on", "note_off", "control_change", "program_change"] + channel: int | None = Field(default=None, ge=0, le=15) + number: int = Field(ge=0, le=127) + action: Literal[ + "activate_scene", + "deactivate_scene", + "toggle_scene", + "flash_scene", + "blackout_on", + "blackout_off", + "blackout_toggle", + "set_master_dimmer", + "set_scene_intensity", + "start_effect", + "stop_effect", + ] + target_type: Literal["scene", "effect", "system", "global"] = "scene" + target_id: str | None = None + mode: Literal["trigger", "toggle", "hold", "flash", "continuous"] = "trigger" + minimum_value: int = Field(default=0, ge=0, le=127) + maximum_value: int = Field(default=127, ge=0, le=127) + + +class MidiLearnStartPayload(BaseModel): + timeout_seconds: int = Field(default=15, ge=3, le=120) + allow_passthrough: bool = False + + +class MidiTestMappingPayload(BaseModel): + value: int = Field(default=127, ge=0, le=127) + message_type: Literal["note_on", "note_off", "control_change", "program_change"] | None = None diff --git a/backend/app/patch/__init__.py b/backend/app/patch/__init__.py new file mode 100644 index 0000000..82499da --- /dev/null +++ b/backend/app/patch/__init__.py @@ -0,0 +1 @@ +"""Patch persistence and validation services.""" diff --git a/backend/app/patch/service.py b/backend/app/patch/service.py new file mode 100644 index 0000000..3b0e91f --- /dev/null +++ b/backend/app/patch/service.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.database import SessionLocal +from app.models.entities import FixtureDefinition, FixtureInstance +from app.models.schemas import PatchPayload, PatchValidationPayload + + +class PatchService: + def __init__(self, session_factory: async_sessionmaker[AsyncSession] = SessionLocal) -> None: + self._session_factory = session_factory + + async def list_instances(self) -> list[dict[str, object]]: + async with self._session_factory() as session: + result = await session.execute(select(FixtureInstance).order_by(FixtureInstance.universe, FixtureInstance.start_address)) + instances = result.scalars().all() + fixtures = await self._definitions_by_ids(session, {instance.definition_id for instance in instances}) + return [self._serialize_instance(instance, fixtures.get(instance.definition_id)) for instance in instances] + + async def validate(self, payload: PatchValidationPayload) -> dict[str, object]: + async with self._session_factory() as session: + definition = await session.get(FixtureDefinition, payload.definition_id) + if definition is None: + raise LookupError("Fixture not found") + mode = self._resolve_mode(definition, payload.mode_key) + if mode is None: + raise LookupError("Fixture mode not found") + + channel_count = int(mode["channel_count"]) + end_address = payload.start_address + channel_count - 1 + conflicts: list[dict[str, object]] = [] + if end_address > 512: + conflicts.append( + { + "type": "range", + "message": f"Patch {payload.start_address}-{end_address} overskrider DMX kanal 512.", + } + ) + + result = await session.execute( + select(FixtureInstance).where(FixtureInstance.universe == payload.universe).order_by(FixtureInstance.start_address) + ) + instances = result.scalars().all() + fixtures = await self._definitions_by_ids(session, {instance.definition_id for instance in instances}) + for instance in instances: + if payload.exclude_patch_id is not None and instance.id == payload.exclude_patch_id: + continue + instance_end = instance.start_address + instance.channel_count - 1 + overlaps = not (instance_end < payload.start_address or instance.start_address > end_address) + if overlaps: + definition_for_instance = fixtures.get(instance.definition_id) + conflicts.append( + { + "type": "overlap", + "patch_id": instance.id, + "name": instance.name, + "range": f"{instance.start_address}-{instance_end}", + "fixture": self._definition_label(definition_for_instance), + "message": f"{instance.name} optager allerede {instance.start_address}-{instance_end}.", + } + ) + + occupied = sorted( + { + address + for instance in instances + if payload.exclude_patch_id is None or instance.id != payload.exclude_patch_id + for address in range(instance.start_address, instance.start_address + instance.channel_count) + } + ) + return { + "valid": not conflicts, + "range": f"{payload.start_address}-{end_address}", + "end_address": end_address, + "channel_count": channel_count, + "occupied_channels": occupied, + "conflicts": conflicts, + } + + async def create_instance(self, payload: PatchPayload) -> dict[str, object]: + validation = await self.validate( + PatchValidationPayload( + universe=payload.universe, + definition_id=payload.definition_id, + mode_key=payload.mode_key, + start_address=payload.start_address, + enabled=payload.enabled, + ) + ) + if not validation["valid"]: + raise ValueError("Patch overlapper eller overskrider kanalrangen") + + async with self._session_factory() as session: + definition = await session.get(FixtureDefinition, payload.definition_id) + if definition is None: + raise LookupError("Fixture not found") + mode = self._resolve_mode(definition, payload.mode_key) + if mode is None: + raise LookupError("Fixture mode not found") + + instance = FixtureInstance( + universe=payload.universe, + name=payload.name, + definition_id=payload.definition_id, + mode_key=payload.mode_key, + start_address=payload.start_address, + channel_count=int(mode["channel_count"]), + enabled=payload.enabled, + group_names=self._sanitize_groups(payload.group_names), + position=payload.position.model_dump(), + tags=[], + ) + session.add(instance) + await session.commit() + await session.refresh(instance) + return self._serialize_instance(instance, definition) + + async def update_instance(self, patch_id: int, payload: PatchPayload) -> dict[str, object]: + validation = await self.validate( + PatchValidationPayload( + universe=payload.universe, + definition_id=payload.definition_id, + mode_key=payload.mode_key, + start_address=payload.start_address, + enabled=payload.enabled, + exclude_patch_id=patch_id, + ) + ) + if not validation["valid"]: + raise ValueError("Patch overlapper eller overskrider kanalrangen") + + async with self._session_factory() as session: + instance = await session.get(FixtureInstance, patch_id) + if instance is None: + raise LookupError("Patch not found") + definition = await session.get(FixtureDefinition, payload.definition_id) + if definition is None: + raise LookupError("Fixture not found") + mode = self._resolve_mode(definition, payload.mode_key) + if mode is None: + raise LookupError("Fixture mode not found") + + instance.universe = payload.universe + instance.name = payload.name + instance.definition_id = payload.definition_id + instance.mode_key = payload.mode_key + instance.start_address = payload.start_address + instance.channel_count = int(mode["channel_count"]) + instance.enabled = payload.enabled + instance.group_names = self._sanitize_groups(payload.group_names) + instance.position = payload.position.model_dump() + await session.commit() + await session.refresh(instance) + return self._serialize_instance(instance, definition) + + async def delete_instance(self, patch_id: int) -> dict[str, object]: + async with self._session_factory() as session: + instance = await session.get(FixtureInstance, patch_id) + if instance is None: + raise LookupError("Patch not found") + deleted_name = instance.name + await session.delete(instance) + await session.commit() + return {"deleted": deleted_name, "id": patch_id} + + async def _definitions_by_ids(self, session: AsyncSession, ids: set[int]) -> dict[int, FixtureDefinition]: + if not ids: + return {} + result = await session.execute(select(FixtureDefinition).where(FixtureDefinition.id.in_(ids))) + definitions = result.scalars().all() + return {definition.id: definition for definition in definitions} + + def _resolve_mode(self, definition: FixtureDefinition, mode_key: str) -> dict[str, Any] | None: + modes = definition.normalized_data.get("modes", []) + if not isinstance(modes, list): + return None + for mode in modes: + if isinstance(mode, dict) and str(mode.get("key")) == mode_key: + return mode + return None + + def _serialize_instance( + self, + instance: FixtureInstance, + definition: FixtureDefinition | None, + ) -> dict[str, object]: + end_address = instance.start_address + instance.channel_count - 1 + mode = self._resolve_mode(definition, instance.mode_key) if definition is not None else None + return { + "id": instance.id, + "universe": instance.universe, + "name": instance.name, + "definition_id": instance.definition_id, + "fixture_slug": definition.slug if definition is not None else None, + "manufacturer": definition.manufacturer if definition is not None else None, + "model": definition.model if definition is not None else None, + "mode_key": instance.mode_key, + "start_address": instance.start_address, + "end_address": end_address, + "channel_count": instance.channel_count, + "enabled": instance.enabled, + "group_names": self._sanitize_groups(instance.group_names), + "position": self._serialize_position(instance.position), + "channels": mode.get("channels", []) if isinstance(mode, dict) else [], + } + + def _definition_label(self, definition: FixtureDefinition | None) -> str: + if definition is None: + return "Ukendt fixture" + return f"{definition.manufacturer} {definition.model}" + + def _sanitize_groups(self, groups: list[str]) -> list[str]: + unique: list[str] = [] + seen: set[str] = set() + for group in groups: + normalized = str(group).strip() + key = normalized.casefold() + if not normalized or key in seen: + continue + unique.append(normalized) + seen.add(key) + return unique + + def _serialize_position(self, payload: object) -> dict[str, float]: + if not isinstance(payload, dict): + payload = {} + return { + "x": float(payload.get("x", 50.0)), + "y": float(payload.get("y", 50.0)), + "z": float(payload.get("z", 0.0)), + "rotation": float(payload.get("rotation", 0.0)), + } diff --git a/backend/app/scenes/__init__.py b/backend/app/scenes/__init__.py new file mode 100644 index 0000000..38cc5c4 --- /dev/null +++ b/backend/app/scenes/__init__.py @@ -0,0 +1,2 @@ +"""Scene services.""" + diff --git a/backend/app/scenes/service.py b/backend/app/scenes/service.py new file mode 100644 index 0000000..1d5bf91 --- /dev/null +++ b/backend/app/scenes/service.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.database import SessionLocal +from app.dmx.engine import DmxEngine +from app.dmx.frame import FrameLayer +from app.models.entities import Scene +from app.models.schemas import ScenePayload +from app.patch.service import PatchService + + +class SceneService: + def __init__( + self, + engine: DmxEngine, + session_factory: async_sessionmaker[AsyncSession] = SessionLocal, + ) -> None: + self.engine = engine + self._session_factory = session_factory + self.patch = PatchService(session_factory) + self.active_slug: str | None = None + + async def save(self, payload: ScenePayload, scene_id: int | None = None) -> dict[str, object]: + async with self._session_factory() as session: + scene = await self._load_for_write(session, payload.slug, scene_id) + if scene is None: + scene = Scene( + name=payload.name, + slug=payload.slug, + color=payload.color, + icon=payload.icon, + priority=payload.priority, + fade_in_ms=payload.fade_in_ms, + fade_out_ms=payload.fade_out_ms, + hold_ms=payload.hold_ms, + master_limit=payload.master_limit, + values=[], + targets=[], + tags=payload.tags, + ) + session.add(scene) + + scene.name = payload.name + scene.slug = payload.slug + scene.color = payload.color + scene.icon = payload.icon + scene.priority = payload.priority + scene.fade_in_ms = payload.fade_in_ms + scene.fade_out_ms = payload.fade_out_ms + scene.hold_ms = payload.hold_ms + scene.master_limit = payload.master_limit + scene.values = [item.model_dump() for item in payload.values] + scene.targets = [item.model_dump() for item in payload.targets] + scene.tags = payload.tags + await session.commit() + await session.refresh(scene) + return self._serialize_scene(scene) + + async def list(self) -> list[dict[str, object]]: + async with self._session_factory() as session: + result = await session.execute(select(Scene).order_by(Scene.priority, Scene.created_at, Scene.id)) + return [self._serialize_scene(scene) for scene in result.scalars().all()] + + async def get(self, scene_id: int) -> dict[str, object]: + async with self._session_factory() as session: + scene = await session.get(Scene, scene_id) + if scene is None: + raise LookupError("Scene not found") + return self._serialize_scene(scene) + + async def activate(self, slug: str) -> dict[str, object]: + async with self._session_factory() as session: + result = await session.execute(select(Scene).where(Scene.slug == slug).limit(1)) + scene = result.scalar_one_or_none() + if scene is None: + raise LookupError("Scene not found") + + values_by_universe, precedence_map_by_universe = await self._resolve_layer_data(scene) + self.engine.set_layer( + FrameLayer( + name=f"scene:{scene.slug}", + priority=scene.priority, + values_by_universe=values_by_universe, + precedence_map_by_universe=precedence_map_by_universe, + ) + ) + self.active_slug = slug + return self._serialize_scene(scene) + + async def release(self, slug: str) -> None: + self.engine.remove_layer(f"scene:{slug}") + if self.active_slug == slug: + self.active_slug = None + + async def delete(self, scene_id: int) -> dict[str, object]: + async with self._session_factory() as session: + scene = await session.get(Scene, scene_id) + if scene is None: + raise LookupError("Scene not found") + slug = scene.slug + await self.release(slug) + await session.delete(scene) + await session.commit() + return {"deleted": slug} + + async def _load_for_write( + self, + session: AsyncSession, + slug: str, + scene_id: int | None, + ) -> Scene | None: + if scene_id is not None: + scene = await session.get(Scene, scene_id) + if scene is None: + raise LookupError("Scene not found") + result = await session.execute(select(Scene).where(Scene.slug == slug, Scene.id != scene_id).limit(1)) + if result.scalar_one_or_none() is not None: + raise ValueError("Scene slug findes allerede") + return scene + + result = await session.execute(select(Scene).where(Scene.slug == slug).limit(1)) + return result.scalar_one_or_none() + + async def _resolve_layer_data(self, scene: Scene) -> tuple[dict[int, dict[int, int]], dict[int, dict[int, str]]]: + values_by_universe: dict[int, dict[int, int]] = {1: {}} + precedence_map_by_universe: dict[int, dict[int, str]] = {1: {}} + + for entry in scene.values: + if not isinstance(entry, dict): + continue + if "channel" not in entry: + continue + channel = int(entry["channel"]) + if not 1 <= channel <= 512: + continue + values_by_universe.setdefault(1, {})[channel] = max(0, min(255, int(entry.get("value", 0)))) + precedence_map_by_universe.setdefault(1, {})[channel] = str(entry.get("precedence", "ltp")).lower() + + if not scene.targets: + return values_by_universe, precedence_map_by_universe + + patches = await self.patch.list_instances() + for target in scene.targets: + if not isinstance(target, dict): + continue + matches = self._resolve_target_matches(target, patches) + attribute_values = target.get("values", []) + if not isinstance(attribute_values, list): + continue + for patch in matches: + for attribute in attribute_values: + if not isinstance(attribute, dict): + continue + channel = self._match_patch_channel(patch, str(attribute.get("attribute", ""))) + if channel is None: + continue + absolute_channel = int(patch["start_address"]) + int(channel["index"]) - 1 + universe = int(patch.get("universe", 1)) + values_by_universe.setdefault(universe, {})[absolute_channel] = max( + 0, min(255, int(attribute.get("value", 0))) + ) + precedence_map_by_universe.setdefault(universe, {})[absolute_channel] = str( + attribute.get("precedence") or channel.get("precedence", "ltp") + ).lower() + return values_by_universe, precedence_map_by_universe + + def _resolve_target_matches( + self, + target: dict[str, object], + patches: list[dict[str, object]], + ) -> list[dict[str, object]]: + target_type = str(target.get("target_type", "fixture")) + if target_type == "group": + group_name = str(target.get("group_name", "")).strip() + if not group_name: + return [] + group_key = group_name.casefold() + return [ + patch + for patch in patches + if any(str(name).casefold() == group_key for name in patch.get("group_names", [])) + ] + + patch_id = target.get("patch_id") + if patch_id is None: + return [] + return [patch for patch in patches if int(patch["id"]) == int(patch_id)] + + def _match_patch_channel( + self, + patch: dict[str, object], + attribute_name: str, + ) -> dict[str, object] | None: + normalized = self._normalize_attribute(attribute_name) + for channel in patch.get("channels", []): + if not isinstance(channel, dict): + continue + candidates = { + self._normalize_attribute(str(channel.get("key", ""))), + self._normalize_attribute(str(channel.get("display_name", ""))), + } + if normalized in candidates: + return channel + return None + + def _normalize_attribute(self, value: str) -> str: + return "".join(character for character in value.casefold() if character.isalnum()) + + def _serialize_scene(self, scene: Scene) -> dict[str, object]: + return { + "id": scene.id, + "name": scene.name, + "slug": scene.slug, + "color": scene.color, + "icon": scene.icon, + "priority": scene.priority, + "fade_in_ms": scene.fade_in_ms, + "fade_out_ms": scene.fade_out_ms, + "hold_ms": scene.hold_ms, + "master_limit": scene.master_limit, + "values": scene.values, + "targets": scene.targets, + "tags": scene.tags, + "is_active": scene.slug == self.active_slug, + } diff --git a/backend/app/system/__init__.py b/backend/app/system/__init__.py new file mode 100644 index 0000000..545bc87 --- /dev/null +++ b/backend/app/system/__init__.py @@ -0,0 +1,3 @@ +from app.system.service import SystemControlService + +__all__ = ["SystemControlService"] diff --git a/backend/app/system/service.py b/backend/app/system/service.py new file mode 100644 index 0000000..0fa96de --- /dev/null +++ b/backend/app/system/service.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import asyncio +import os +from dataclasses import dataclass + +from app.core.config import get_settings + + +@dataclass(slots=True) +class SystemCommandResult: + accepted: bool + action: str + status: str + detail: str + + def as_dict(self) -> dict[str, object]: + return { + "accepted": self.accepted, + "action": self.action, + "status": self.status, + "detail": self.detail, + } + + +class SystemControlService: + def __init__(self) -> None: + settings = get_settings() + self._enabled = settings.system_control_enabled + self._platform = os.name + self._helper = settings.system_control_helper + + async def restart_service(self) -> dict[str, object]: + return (await self._run_helper( + action="restart-service", + helper_action="restart-service", + )).as_dict() + + async def reboot_host(self) -> dict[str, object]: + return (await self._run_helper( + action="reboot-host", + helper_action="reboot-host", + )).as_dict() + + async def _run_helper( + self, + *, + action: str, + helper_action: str, + ) -> SystemCommandResult: + if not self._enabled: + return SystemCommandResult( + accepted=False, + action=action, + status="disabled", + detail="Systemkontrol er deaktiveret i serverkonfigurationen.", + ) + if self._platform != "posix": + return SystemCommandResult( + accepted=False, + action=action, + status="unsupported-platform", + detail="Systemkontrol understoettes kun paa Linux/systemd-installationer.", + ) + if not os.path.exists(self._helper): + return SystemCommandResult( + accepted=False, + action=action, + status="helper-missing", + detail="Systemkontrol-helperen findes ikke paa maskinen.", + ) + + process = await asyncio.create_subprocess_exec( + "sudo", + "-n", + self._helper, + helper_action, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + if process.returncode != 0: + error_output = (stderr or stdout).decode("utf-8", errors="ignore").strip() or "Ukendt fejl" + return SystemCommandResult( + accepted=False, + action=action, + status="command-failed", + detail=f"Kunne ikke planlaegge handlingen: {error_output}", + ) + + return SystemCommandResult( + accepted=True, + action=action, + status="scheduled", + detail="Handling planlagt. Serveren kan blive utilgaengelig et kort oeje.", + ) diff --git a/backend/app/telemetry/__init__.py b/backend/app/telemetry/__init__.py new file mode 100644 index 0000000..e09ed82 --- /dev/null +++ b/backend/app/telemetry/__init__.py @@ -0,0 +1,2 @@ +"""Telemetry services.""" + diff --git a/backend/app/telemetry/service.py b/backend/app/telemetry/service.py new file mode 100644 index 0000000..f983640 --- /dev/null +++ b/backend/app/telemetry/service.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from datetime import UTC, datetime +from time import monotonic + +import psutil + +from app.dmx.backends import BackendStatus + + +@dataclass(slots=True) +class TelemetryEvent: + created_at: datetime + category: str + level: str + message: str + + +class TelemetryService: + def __init__(self) -> None: + self.started_at = monotonic() + self.events: deque[TelemetryEvent] = deque(maxlen=1000) + self.last_sent_at: datetime | None = None + self.last_error: str | None = None + self.reconnect_count = 0 + self.frames_sent = 0 + self.send_errors = 0 + + def record_send_success(self, status: BackendStatus) -> None: + self.last_sent_at = datetime.now(UTC) + self.last_error = None + self.reconnect_count = status.reconnect_count + self.frames_sent = status.frames_sent + self.send_errors = status.send_errors + + def record_send_failure(self, error: str, status: BackendStatus) -> None: + self.last_error = error + self.reconnect_count = status.reconnect_count + self.frames_sent = status.frames_sent + self.send_errors = status.send_errors + self.events.append( + TelemetryEvent(datetime.now(UTC), "DMX", "ERROR", error) + ) + + def snapshot(self, queue_depth: int = 0) -> dict[str, object]: + temperature = None + try: + readings = psutil.sensors_temperatures() + if readings: + first = next(iter(readings.values())) + if first: + temperature = float(first[0].current) + except Exception: + temperature = None + + return { + "uptime_seconds": round(monotonic() - self.started_at, 2), + "cpu_percent": psutil.cpu_percent(interval=None), + "ram_percent": psutil.virtual_memory().percent, + "temperature_c": temperature, + "queue_depth": queue_depth, + "reconnect_count": self.reconnect_count, + "last_error": self.last_error, + "last_sent_at": self.last_sent_at, + "frames_sent": self.frames_sent, + "send_errors": self.send_errors, + "events": [ + { + "created_at": event.created_at.isoformat(), + "category": event.category, + "level": event.level, + "message": event.message, + } + for event in list(self.events)[-50:] + ], + } diff --git a/backend/app/websocket/__init__.py b/backend/app/websocket/__init__.py new file mode 100644 index 0000000..285e20e --- /dev/null +++ b/backend/app/websocket/__init__.py @@ -0,0 +1,2 @@ +"""WebSocket helpers.""" + diff --git a/backend/app/websocket/router.py b/backend/app/websocket/router.py new file mode 100644 index 0000000..edd3f3f --- /dev/null +++ b/backend/app/websocket/router.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.core.dependencies import app_state + +websocket_router = APIRouter() + + +@websocket_router.websocket("/ws/live") +async def live_socket(websocket: WebSocket) -> None: + await websocket.accept() + try: + while True: + await websocket.send_json( + { + "engine": app_state.engine.snapshot(), + "telemetry": app_state.telemetry.snapshot(len(app_state.triggers.queue)), + "bpm": app_state.bpm.snapshot(), + "queue": app_state.triggers.snapshot(), + } + ) + await asyncio.sleep(0.1) + except WebSocketDisconnect: + return diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..5801bea --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..761cdae --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,593 @@ +from uuid import uuid4 + +from app.core.dependencies import app_state +from app.main import app +from fastapi.testclient import TestClient + + +def test_health_endpoint() -> None: + with TestClient(app) as client: + response = client.get("/api/v1/health") + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "ok" + assert payload["simulator_enabled"] is True + + +def test_trigger_endpoint_returns_202() -> None: + with TestClient(app) as client: + response = client.post( + "/api/v1/triggers/raid", + json={"eventType": "raid", "displayName": "Tester", "eventId": "evt-1"}, + ) + assert response.status_code == 202 + assert response.json()["accepted"] is True + + +def test_dmx_status_exposes_backend_counters() -> None: + with TestClient(app) as client: + response = client.get("/api/v1/dmx/status") + assert response.status_code == 200 + payload = response.json() + assert payload["backend"] in {"simulator", "ola", "artnet"} + assert payload["selected_universe"] == 1 + assert "selected_output_port" in payload + assert payload["frames_sent"] >= 0 + assert payload["send_errors"] >= 0 + assert "last_successful_frame" in payload + + +def test_dmx_output_config_can_be_saved_and_read_back() -> None: + with TestClient(app) as client: + original = client.get("/api/v1/dmx/config").json() + + response = client.put( + "/api/v1/dmx/config", + json={ + "backend": "artnet", + "universe": 7, + "output_port": "WLED stue", + "target_host": "192.168.2.50", + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["backend"] == "artnet" + assert payload["universe"] == 7 + assert payload["output_port"] == "WLED stue" + assert payload["target_host"] == "192.168.2.50" + + read_back = client.get("/api/v1/dmx/config") + assert read_back.status_code == 200 + assert read_back.json()["backend"] == "artnet" + assert read_back.json()["universe"] == 7 + + restore_response = client.put("/api/v1/dmx/config", json=original) + assert restore_response.status_code == 200 + + +def test_dmx_artnet_discover_endpoint_returns_nodes(monkeypatch) -> None: + async def fake_discover(timeout_s: float) -> dict[str, object]: + assert timeout_s == 0.25 + return { + "count": 1, + "items": [ + { + "ip": "192.168.2.60", + "short_name": "WLED", + "long_name": "WLED Node", + "label": "WLED Node (192.168.2.60)", + "net": 0, + "sub_switch": 0, + "port_count": 1, + "raw_port_address": 0, + } + ], + } + + monkeypatch.setattr(app_state.dmx, "discover_artnet", fake_discover) + + with TestClient(app) as client: + response = client.post("/api/v1/dmx/artnet/discover?timeout_s=0.25") + assert response.status_code == 200 + payload = response.json() + assert payload["count"] == 1 + assert payload["items"][0]["ip"] == "192.168.2.60" + + +def test_home_assistant_config_can_be_saved_and_mapping_created(monkeypatch) -> None: + with TestClient(app) as client: + config_response = client.put( + "/api/v1/integrations/home-assistant/config", + json={ + "enabled": True, + "base_url": "http://ha.local:8123", + "token": "secret-token", + "default_universe": 10, + }, + ) + assert config_response.status_code == 200 + assert config_response.json()["enabled"] is True + assert config_response.json()["base_url"] == "http://ha.local:8123" + assert config_response.json()["has_token"] is True + assert "token" not in config_response.json() + assert config_response.json()["token_mask"] == "********" + + create_response = client.post( + "/api/v1/integrations/home-assistant/mappings", + json={ + "name": "HA RGB", + "universe": 10, + "start_address": 1, + "fixture_type": "rgb", + "entity_id": "light.bar_rgb", + "rate_limit_hz": 5, + "deadband": 2, + "fade_ms": 150, + "invert_channel": False, + "min_value": 0, + "max_value": 255, + "enabled": True, + "master_dimmer": True, + }, + ) + assert create_response.status_code == 200 + mapping = create_response.json() + assert mapping["universe"] == 10 + assert mapping["channel_span"] == 4 + + async def fake_test_mapping(mapping_id: int) -> dict[str, object]: + return {"status": "sent", "mapping_id": mapping_id} + + monkeypatch.setattr(app_state.home_assistant, "test_mapping", fake_test_mapping) + test_response = client.post(f"/api/v1/integrations/home-assistant/mappings/{mapping['id']}/test") + assert test_response.status_code == 200 + assert test_response.json()["status"] == "sent" + + delete_response = client.delete(f"/api/v1/integrations/home-assistant/mappings/{mapping['id']}") + assert delete_response.status_code == 200 + + +def test_home_assistant_connection_endpoint_returns_status(monkeypatch) -> None: + async def fake_test_connection() -> dict[str, object]: + return { + "reachable": True, + "auth_ok": True, + "ha_version": "2026.7.0", + "last_error": None, + "last_successful_call_at": "2026-07-23T12:45:00+02:00", + "last_connection_success_at": "2026-07-23T12:45:00+02:00", + } + + monkeypatch.setattr(app_state.home_assistant, "test_connection", fake_test_connection) + + with TestClient(app) as client: + response = client.post("/api/v1/integrations/home-assistant/test-connection") + assert response.status_code == 200 + payload = response.json() + assert payload["reachable"] is True + assert payload["auth_ok"] is True + assert payload["ha_version"] == "2026.7.0" + + +def test_bpm_config_can_be_saved_and_read_back() -> None: + with TestClient(app) as client: + original = client.get("/api/v1/bpm/config").json()["preferred_device"] + response = client.put( + "/api/v1/bpm/config", + json={"preferred_device": "alsa:plughw:CARD=SB,DEV=0"}, + ) + assert response.status_code == 200 + assert response.json()["preferred_device"] == "alsa:plughw:CARD=SB,DEV=0" + + status_response = client.get("/api/v1/bpm/status") + assert status_response.status_code == 200 + assert status_response.json()["selected_device"] == "alsa:plughw:CARD=SB,DEV=0" + + restore_response = client.put( + "/api/v1/bpm/config", + json={"preferred_device": original}, + ) + assert restore_response.status_code == 200 + + +def test_restart_service_endpoint_returns_scheduler_result(monkeypatch) -> None: + async def fake_restart_service() -> dict[str, object]: + return { + "accepted": True, + "action": "restart-service", + "status": "scheduled", + "detail": "Servicegenstart planlagt.", + } + + monkeypatch.setattr(app_state.system, "restart_service", fake_restart_service) + + with TestClient(app) as client: + response = client.post("/api/v1/system/restart-service") + assert response.status_code == 200 + assert response.json()["accepted"] is True + assert response.json()["action"] == "restart-service" + + +def test_reboot_host_endpoint_returns_scheduler_result(monkeypatch) -> None: + async def fake_reboot_host() -> dict[str, object]: + return { + "accepted": True, + "action": "reboot-host", + "status": "scheduled", + "detail": "Hostgenstart planlagt.", + } + + monkeypatch.setattr(app_state.system, "reboot_host", fake_reboot_host) + + with TestClient(app) as client: + response = client.post("/api/v1/system/reboot-host") + assert response.status_code == 200 + assert response.json()["accepted"] is True + assert response.json()["action"] == "reboot-host" + + +def test_delete_scene_releases_active_layer() -> None: + with TestClient(app) as client: + app_state.scenes.active_slug = None + app_state.engine.layers.clear() + + existing_scenes = client.get("/api/v1/scenes").json()["items"] + for scene in existing_scenes: + if scene["slug"] == "brr3-test": + client.delete(f"/api/v1/scenes/{scene['id']}") + + create_response = client.post( + "/api/v1/scenes", + json={ + "name": "BRR-3 test", + "slug": "brr3-test", + "priority": 10, + "values": [{"channel": 10, "value": 255, "precedence": "htp", "source": "scene"}], + }, + ) + assert create_response.status_code == 200 + + activate_response = client.post("/api/v1/scenes/brr3-test/activate") + assert activate_response.status_code == 200 + assert "scene:brr3-test" in app_state.engine.layers + + scene_id = create_response.json()["id"] + delete_response = client.delete(f"/api/v1/scenes/{scene_id}") + assert delete_response.status_code == 200 + assert delete_response.json() == {"deleted": "brr3-test"} + assert "scene:brr3-test" not in app_state.engine.layers + assert app_state.scenes.active_slug is None + + +def test_imported_fixture_persists_and_can_be_patched() -> None: + universe = 101 + unique_key = f"brr3-{uuid4().hex[:8]}" + payload = { + "manufacturer": "Eurolite", + "model": "BRR-3 Test", + "categories": ["Color Changer"], + "source": {"manufacturer_key": "eurolite", "fixture_key": unique_key}, + "modes": [ + { + "key": "6ch", + "channels": [ + {"key": "Red", "display_name": "Red", "precedence": "ltp", "resolution": 8}, + {"key": "Green", "display_name": "Green", "precedence": "ltp", "resolution": 8}, + {"key": "Blue", "display_name": "Blue", "precedence": "ltp", "resolution": 8}, + {"key": "White", "display_name": "White", "precedence": "ltp", "resolution": 8}, + {"key": "Amber", "display_name": "Amber", "precedence": "ltp", "resolution": 8}, + {"key": "UV", "display_name": "UV", "precedence": "ltp", "resolution": 8}, + ], + } + ], + } + + with TestClient(app) as client: + import_response = client.post("/api/v1/fixtures/import-file", json=payload) + assert import_response.status_code == 200 + fixture = import_response.json() + fixture_id = fixture["id"] + assert fixture["manufacturer"] == "Eurolite" + assert fixture["modes"][0]["channel_count"] == 6 + + list_response = client.get("/api/v1/fixtures") + assert list_response.status_code == 200 + assert any(item["id"] == fixture_id for item in list_response.json()["items"]) + + validate_response = client.post( + "/api/v1/patch/validate", + json={ + "universe": universe, + "definition_id": fixture_id, + "mode_key": "6ch", + "start_address": 10, + }, + ) + assert validate_response.status_code == 200 + assert validate_response.json()["valid"] is True + assert validate_response.json()["range"] == "10-15" + + create_patch_response = client.post( + "/api/v1/patch", + json={ + "universe": universe, + "name": "Eurolite venstre", + "definition_id": fixture_id, + "mode_key": "6ch", + "start_address": 10, + "enabled": True, + "group_names": ["front", "wash"], + "position": {"x": 20, "y": 35, "z": 8, "rotation": 15}, + }, + ) + assert create_patch_response.status_code == 201 + patch = create_patch_response.json() + assert patch["start_address"] == 10 + assert patch["end_address"] == 15 + assert patch["group_names"] == ["front", "wash"] + assert patch["position"]["x"] == 20 + + conflict_response = client.post( + "/api/v1/patch", + json={ + "universe": universe, + "name": "Eurolite højre", + "definition_id": fixture_id, + "mode_key": "6ch", + "start_address": 12, + "enabled": True, + "group_names": [], + "position": {"x": 70, "y": 35, "z": 8, "rotation": 345}, + }, + ) + assert conflict_response.status_code == 409 + + delete_patch_response = client.delete(f"/api/v1/patch/{patch['id']}") + assert delete_patch_response.status_code == 200 + + delete_fixture_response = client.delete(f"/api/v1/fixtures/{fixture_id}") + assert delete_fixture_response.status_code == 204 + + +def test_import_ofl_style_fixture_file_supports_mode_channels() -> None: + payload = { + "$schema": "https://raw.githubusercontent.com/OpenLightingProject/open-fixture-library/master/schemas/fixture.json", + "manufacturer": "Eurolite", + "name": "LED Bar-3 HCL Bar", + "categories": ["Color Changer"], + "source": { + "manufacturer_key": "eurolite", + "fixture_key": f"led-bar-3-hcl-bar-{uuid4().hex[:8]}", + }, + "availableChannels": { + "Dimmer": {"capability": {"type": "Intensity"}}, + "Strobe": { + "capabilities": [ + {"dmxRange": [0, 9], "type": "NoFunction"}, + {"dmxRange": [10, 255], "type": "ShutterStrobe"}, + ] + }, + "Red": {"capability": {"type": "ColorIntensity", "color": "Red"}}, + "Green": {"capability": {"type": "ColorIntensity", "color": "Green"}}, + "Blue": {"capability": {"type": "ColorIntensity", "color": "Blue"}}, + "White": {"capability": {"type": "ColorIntensity", "color": "White"}}, + "Amber": {"capability": {"type": "ColorIntensity", "color": "Amber"}}, + "UV": {"capability": {"type": "ColorIntensity", "color": "UV"}}, + }, + "modes": [ + {"name": "6ch", "channels": ["Red", "Green", "Blue", "White", "Amber", "UV"]}, + {"name": "10ch", "channels": ["Dimmer", "Strobe", "Red", "Green", "Blue", "White", "Amber", "UV"]}, + ], + } + + with TestClient(app) as client: + response = client.post("/api/v1/fixtures/import-file", json=payload) + assert response.status_code == 200 + fixture = response.json() + assert fixture["manufacturer"] == "Eurolite" + assert fixture["model"] == "LED Bar-3 HCL Bar" + assert fixture["source"]["manufacturer_key"] == "eurolite" + assert fixture["modes"][0]["channels"][0]["key"] == "Red" + assert fixture["modes"][1]["channels"][0]["precedence"] == "htp" + assert fixture["modes"][1]["channels"][1]["capabilities"][1]["type"] == "ShutterStrobe" + + delete_fixture_response = client.delete(f"/api/v1/fixtures/{fixture['id']}") + assert delete_fixture_response.status_code == 204 + + +def test_live_mixer_returns_and_updates_patched_fixture() -> None: + universe = 102 + unique_key = f"live-mixer-{uuid4().hex[:8]}" + payload = { + "manufacturer": "Eurolite", + "model": "LED Bar-3 HCL Bar", + "categories": ["Color Changer"], + "source": {"manufacturer_key": "eurolite", "fixture_key": unique_key}, + "modes": [ + { + "key": "10-channel", + "channels": [ + {"key": "Dimmer", "display_name": "Dimmer", "precedence": "htp", "resolution": 8}, + {"key": "Strobe", "display_name": "Strobe", "precedence": "ltp", "resolution": 8}, + {"key": "Red", "display_name": "Red", "precedence": "ltp", "resolution": 8}, + ], + } + ], + } + + with TestClient(app) as client: + fixture = client.post("/api/v1/fixtures/import-file", json=payload).json() + patch = client.post( + "/api/v1/patch", + json={ + "universe": universe, + "name": "Eurolite live", + "definition_id": fixture["id"], + "mode_key": "10-channel", + "start_address": 10, + "enabled": True, + "group_names": [], + "position": {"x": 40, "y": 50, "z": 5, "rotation": 0}, + }, + ).json() + + mixer_response = client.get("/api/v1/live/mixer") + assert mixer_response.status_code == 200 + assert any(item["patch_id"] == patch["id"] for item in mixer_response.json()["items"]) + + update_response = client.put( + f"/api/v1/live/mixer/{patch['id']}", + json={"values": {"1": 200, "3": 128}}, + ) + assert update_response.status_code == 200 + mixer_item = next(item for item in update_response.json()["items"] if item["patch_id"] == patch["id"]) + dimmer = next(channel for channel in mixer_item["channels"] if channel["index"] == 1) + red = next(channel for channel in mixer_item["channels"] if channel["index"] == 3) + assert dimmer["value"] == 200 + assert red["absolute_channel"] == 12 + + clear_response = client.delete(f"/api/v1/live/mixer/{patch['id']}") + assert clear_response.status_code == 200 + + assert client.delete(f"/api/v1/patch/{patch['id']}").status_code == 200 + assert client.delete(f"/api/v1/fixtures/{fixture['id']}").status_code == 204 + + +def test_live_mixer_returns_and_updates_home_assistant_mapping() -> None: + with TestClient(app) as client: + create_response = client.post( + "/api/v1/integrations/home-assistant/mappings", + json={ + "name": "Loft bunker", + "universe": 10, + "start_address": 1, + "fixture_type": "switch", + "entity_id": "light.loft_bunker", + "rate_limit_hz": 5, + "deadband": 2, + "fade_ms": 0, + "invert_channel": False, + "min_value": 0, + "max_value": 255, + "enabled": True, + "master_dimmer": False, + }, + ) + assert create_response.status_code == 200 + mapping = create_response.json() + + mixer_response = client.get("/api/v1/live/mixer") + assert mixer_response.status_code == 200 + mixer_item = next( + item + for item in mixer_response.json()["items"] + if item["source_type"] == "home_assistant" and item["source_id"] == mapping["id"] + ) + assert mixer_item["entity_id"] == "light.loft_bunker" + assert mixer_item["universe"] == 10 + assert mixer_item["channel_count"] == 1 + + update_response = client.put( + f"/api/v1/live/mixer/home-assistant/{mapping['id']}", + json={"values": {"1": 255}}, + ) + assert update_response.status_code == 200 + updated_item = next( + item + for item in update_response.json()["items"] + if item["source_type"] == "home_assistant" and item["source_id"] == mapping["id"] + ) + assert updated_item["channels"][0]["value"] == 255 + assert updated_item["channels"][0]["absolute_channel"] == 1 + + clear_response = client.delete(f"/api/v1/live/mixer/home-assistant/{mapping['id']}") + assert clear_response.status_code == 200 + + delete_response = client.delete(f"/api/v1/integrations/home-assistant/mappings/{mapping['id']}") + assert delete_response.status_code == 200 + + +def test_scene_group_target_resolves_fixture_channels() -> None: + universe = 103 + unique_key = f"scene-group-{uuid4().hex[:8]}" + payload = { + "manufacturer": "Eurolite", + "model": "LED Bar-3 HCL Bar", + "categories": ["Color Changer"], + "source": {"manufacturer_key": "eurolite", "fixture_key": unique_key}, + "modes": [ + { + "key": "10-channel", + "channels": [ + {"key": "Dimmer", "display_name": "Dimmer", "precedence": "htp", "resolution": 8}, + {"key": "Strobe", "display_name": "Strobe", "precedence": "ltp", "resolution": 8}, + {"key": "Blue", "display_name": "Blue", "precedence": "ltp", "resolution": 8}, + ], + } + ], + } + + with TestClient(app) as client: + fixture = client.post("/api/v1/fixtures/import-file", json=payload).json() + patch_left = client.post( + "/api/v1/patch", + json={ + "universe": universe, + "name": "Bar venstre", + "definition_id": fixture["id"], + "mode_key": "10-channel", + "start_address": 1, + "enabled": True, + "group_names": ["synk-a", "front"], + "position": {"x": 20, "y": 30, "z": 5, "rotation": 10}, + }, + ).json() + patch_right = client.post( + "/api/v1/patch", + json={ + "universe": universe, + "name": "Bar højre", + "definition_id": fixture["id"], + "mode_key": "10-channel", + "start_address": 11, + "enabled": True, + "group_names": ["synk-a", "front"], + "position": {"x": 80, "y": 30, "z": 5, "rotation": 350}, + }, + ).json() + + scene_response = client.post( + "/api/v1/scenes", + json={ + "name": "Front blue", + "slug": "front-blue", + "priority": 20, + "values": [], + "targets": [ + { + "target_type": "group", + "group_name": "synk-a", + "values": [ + {"attribute": "Dimmer", "value": 200}, + {"attribute": "Blue", "value": 128}, + ], + } + ], + }, + ) + assert scene_response.status_code == 200 + + activate_response = client.post("/api/v1/scenes/front-blue/activate") + assert activate_response.status_code == 200 + layer = app_state.engine.layers["scene:front-blue"] + assert layer.values_by_universe[universe][1] == 200 + assert layer.values_by_universe[universe][3] == 128 + assert layer.values_by_universe[universe][11] == 200 + assert layer.values_by_universe[universe][13] == 128 + + scene_id = scene_response.json()["id"] + assert client.delete(f"/api/v1/scenes/{scene_id}").status_code == 200 + assert client.delete(f"/api/v1/patch/{patch_left['id']}").status_code == 200 + assert client.delete(f"/api/v1/patch/{patch_right['id']}").status_code == 200 + assert client.delete(f"/api/v1/fixtures/{fixture['id']}").status_code == 204 diff --git a/backend/tests/test_artnet_backend.py b/backend/tests/test_artnet_backend.py new file mode 100644 index 0000000..17370ef --- /dev/null +++ b/backend/tests/test_artnet_backend.py @@ -0,0 +1,118 @@ +import struct + +import pytest +from app.dmx.backends import ( + ARTNET_HEADER, + ARTNET_OPCODE_DMX, + ARTNET_OPCODE_POLL_REPLY, + ARTNET_PORT, + ArtNetDmxBackend, + build_artnet_dmx_packet, + parse_artnet_poll_reply, +) +from app.dmx.frame import DmxFrame + + +def test_build_artnet_packet_sends_full_512_frame_to_expected_universe() -> None: + values = [0] * 512 + values[0] = 255 + values[9] = 64 + packet = build_artnet_dmx_packet(2, values, sequence=9) + + assert packet.startswith(ARTNET_HEADER) + assert struct.unpack_from("H", packet, 16)[0] == 512 + assert len(packet) == 530 + assert packet[18] == 255 + assert packet[27] == 64 + + +def test_parse_artnet_poll_reply_extracts_node_identity() -> None: + packet = bytearray(239) + packet[0:8] = ARTNET_HEADER + struct.pack_into("H", packet, 172, 4) + packet[190] = 3 + + node = parse_artnet_poll_reply(bytes(packet)) + + assert node is not None + assert node.ip == "192.168.2.55" + assert node.short_name == "WLED Node" + assert node.long_name == "Paravega Test Node" + assert node.port_count == 4 + assert node.raw_port_address == 3 + assert node.label == "Paravega Test Node (192.168.2.55)" + + +@pytest.mark.asyncio +async def test_artnet_backend_sends_udp_packet_and_updates_status(monkeypatch) -> None: + sent_packets: list[tuple[bytes, tuple[str, int]]] = [] + + class FakeSocket: + def setsockopt(self, *_args: object) -> None: + return + + def sendto(self, packet: bytes, address: tuple[str, int]) -> None: + sent_packets.append((packet, address)) + + def close(self) -> None: + return + + backend = ArtNetDmxBackend( + universe=4, + target_host="192.168.2.77", + output_port="Paravega stue", + ) + monkeypatch.setattr(backend, "_socket", FakeSocket()) + frame = DmxFrame(universe=4) + frame.set_channel(1, 200, "scene") + frame.set_channel(4, 99, "scene") + + await backend.send_frame(frame) + + status = backend.get_status() + assert status.connected is True + assert status.degraded is False + assert status.frames_sent == 1 + assert status.send_errors == 0 + assert status.selected_universe == 4 + assert status.selected_output_port == "Paravega stue" + assert sent_packets[0][1] == ("192.168.2.77", ARTNET_PORT) + assert sent_packets[0][0][18] == 200 + assert sent_packets[0][0][21] == 99 + + +@pytest.mark.asyncio +async def test_artnet_backend_marks_degraded_on_socket_error(monkeypatch) -> None: + class FakeSocket: + def setsockopt(self, *_args: object) -> None: + return + + def sendto(self, _packet: bytes, _address: tuple[str, int]) -> None: + raise OSError("Network unreachable") + + def close(self) -> None: + return + + backend = ArtNetDmxBackend(universe=1, target_host="192.168.2.90") + monkeypatch.setattr(backend, "_socket", FakeSocket()) + frame = DmxFrame() + frame.set_channel(1, 255, "scene") + + with pytest.raises(RuntimeError, match="Network unreachable"): + await backend.send_frame(frame) + + status = backend.get_status() + assert status.connected is False + assert status.degraded is True + assert status.send_errors == 1 + assert status.last_error == "Network unreachable" diff --git a/backend/tests/test_backup_restore.py b/backend/tests/test_backup_restore.py new file mode 100644 index 0000000..35ec904 --- /dev/null +++ b/backend/tests/test_backup_restore.py @@ -0,0 +1,35 @@ +from pathlib import Path + +from app.backup.service import BackupService + + +def test_backup_and_restore_roundtrip(tmp_path: Path) -> None: + data_dir = tmp_path / "data" + backup_dir = data_dir / "backups" + data_dir.mkdir(parents=True) + sample_file = data_dir / "settings.json" + sample_file.write_text('{"scene":"base"}', encoding="utf-8") + + service = BackupService(data_dir=data_dir, backup_dir=backup_dir) + created = service.create_backup("audit-backup") + + sample_file.write_text('{"scene":"modified"}', encoding="utf-8") + service.restore_backup(created.id) + + assert sample_file.read_text(encoding="utf-8") == '{"scene":"base"}' + assert any(item.label == "audit-backup" for item in service.list_backups()) + + +def test_restore_empty_backup_clears_runtime_files(tmp_path: Path) -> None: + data_dir = tmp_path / "data" + backup_dir = data_dir / "backups" + data_dir.mkdir(parents=True) + + service = BackupService(data_dir=data_dir, backup_dir=backup_dir) + empty_backup = service.create_backup("empty-runtime") + + sample_file = data_dir / "settings.json" + sample_file.write_text('{"scene":"modified"}', encoding="utf-8") + service.restore_backup(empty_backup.id) + + assert not sample_file.exists() diff --git a/backend/tests/test_bpm_service.py b/backend/tests/test_bpm_service.py new file mode 100644 index 0000000..2f8bc0b --- /dev/null +++ b/backend/tests/test_bpm_service.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import asyncio + +from app.bpm.service import BeatAnalyzer, BpmService + + +def test_beat_analyzer_estimates_click_track_bpm() -> None: + analyzer = BeatAnalyzer(sample_rate=44_100, window_size=1024) + click = (b"\x00\x40" * 2048) + (b"\x00\x00" * (44_100 // 2 - 2048)) + + result = None + for _ in range(6): + result = analyzer.feed_pcm16(click) + + assert result is not None + bpm, confidence = result + assert 117 <= bpm <= 124 + assert confidence > 0.5 + + +def test_bpm_service_synthetic_audio_updates_snapshot() -> None: + async def run_test() -> None: + service = BpmService() + await service.start_audio("synthetic-click-track") + await asyncio.sleep(1.2) + snapshot = service.snapshot() + await service.stop_audio() + + assert snapshot["mode"] == "audio:synthetic-click-track" + assert snapshot["audio_connected"] is True + assert 117 <= float(snapshot["bpm"]) <= 123 + assert float(snapshot["confidence"]) > 0.3 + assert float(snapshot["input_level"]) > 0.0 + assert float(snapshot["peak_level"]) >= float(snapshot["input_level"]) + assert snapshot["clipping"] is False + + asyncio.run(run_test()) + + +def test_parse_alsa_card_line_supports_named_aliases() -> None: + service = BpmService() + + card = service._parse_card_line("card 1: SB [HDA ATI SB], device 0: ALC883 Analog [ALC883 Analog]") + device = service._parse_device_from_card_line( + "card 1: SB [HDA ATI SB], device 0: ALC883 Analog [ALC883 Analog]" + ) + + assert card == ("1", "SB", "HDA ATI SB") + assert device == ("0", "ALC883 Analog", "ALC883 Analog") + + +def test_busy_alsa_error_is_humanized() -> None: + service = BpmService() + + message = service._humanize_alsa_error( + "alsa:plughw:CARD=SB,DEV=0", + "arecord: main:831: audio open error: Device or resource busy", + ) + + assert "Mikrofonen er optaget" in message diff --git a/backend/tests/test_dmx_frame.py b/backend/tests/test_dmx_frame.py new file mode 100644 index 0000000..0939a6d --- /dev/null +++ b/backend/tests/test_dmx_frame.py @@ -0,0 +1,39 @@ +from app.dmx.frame import DmxFrame, FrameLayer, merge_layers + + +def test_frame_has_512_channels() -> None: + frame = DmxFrame() + assert len(frame.values) == 512 + assert all(value == 0 for value in frame.values) + + +def test_htp_chooses_highest_value() -> None: + base = FrameLayer.from_channel_values("base", priority=10, values={1: 80}, precedence_map={1: "htp"}) + overlay = FrameLayer.from_channel_values("overlay", priority=20, values={1: 200}, precedence_map={1: "htp"}) + frames = merge_layers([base, overlay]) + assert frames[1].values[0] == 200 + assert frames[1].source_map[0] == "overlay" + + +def test_ltp_chooses_latest_priority_layer() -> None: + base = FrameLayer.from_channel_values("base", priority=10, values={2: 200}, precedence_map={2: "ltp"}) + overlay = FrameLayer.from_channel_values("overlay", priority=20, values={2: 15}, precedence_map={2: "ltp"}) + frames = merge_layers([base, overlay]) + assert frames[1].values[1] == 15 + assert frames[1].source_map[1] == "overlay" + + +def test_blackout_overrides_layers() -> None: + layer = FrameLayer.from_channel_values("scene", priority=10, values={1: 255, 2: 128}, precedence_map={1: "htp"}) + frames = merge_layers([layer], blackout=True) + assert frames[1].values[0] == 0 + assert frames[1].values[1] == 0 + + +def test_layers_are_merged_per_universe() -> None: + front = FrameLayer.from_channel_values("front", priority=10, values={1: 255}, universe=1) + home_assistant = FrameLayer.from_channel_values("ha", priority=10, values={1: 180, 2: 90}, universe=10) + frames = merge_layers([front, home_assistant]) + assert frames[1].values[0] == 255 + assert frames[10].values[0] == 180 + assert frames[10].values[1] == 90 diff --git a/backend/tests/test_effect_service.py b/backend/tests/test_effect_service.py new file mode 100644 index 0000000..f412691 --- /dev/null +++ b/backend/tests/test_effect_service.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import asyncio + +from app.effects.service import EffectService +from app.models.schemas import EffectPayload + + +class FakeEngine: + def __init__(self) -> None: + self.layers: dict[str, object] = {} + + def set_layer(self, layer) -> None: # type: ignore[no-untyped-def] + self.layers[layer.name] = layer + + def remove_layer(self, name: str) -> None: + self.layers.pop(name, None) + + +class FakeBpm: + def __init__(self) -> None: + self.current_bpm = 180.0 + self.audio_connected = False + self.beat_counter = 0 + + +def test_beat_flash_effect_pulses_layer_from_bpm_clock() -> None: + async def run_test() -> None: + engine = FakeEngine() + bpm = FakeBpm() + service = EffectService(engine, bpm) # type: ignore[arg-type] + payload = EffectPayload( + name="Beat dimmer", + slug="beat-dimmer", + effect_type="beat-flash", + priority=60, + parameters={ + "duration_ms": 90, + "channels": {"1": 255}, + "precedence": {"1": "htp"}, + }, + ) + + service.save(payload) + service.trigger(payload.slug) + await asyncio.sleep(0.03) + assert "effect:beat-dimmer" in engine.layers + + await asyncio.sleep(0.16) + assert "effect:beat-dimmer" not in engine.layers + + service.stop(payload.slug) + await service.shutdown() + + asyncio.run(run_test()) diff --git a/backend/tests/test_home_assistant_service.py b/backend/tests/test_home_assistant_service.py new file mode 100644 index 0000000..2a93255 --- /dev/null +++ b/backend/tests/test_home_assistant_service.py @@ -0,0 +1,682 @@ +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from app.dmx.engine import DmxEngine +from app.dmx.frame import DmxFrame +from app.homeassistant.service import HomeAssistantService +from app.telemetry.service import TelemetryService + + +class FakeHomeAssistantAdapter: + def __init__(self) -> None: + self.calls: list[tuple[str, str, str, dict[str, object]]] = [] + self.list_payload: list[dict[str, object]] = [ + { + "entity_id": "light.bar_rgb", + "state": "on", + "attributes": { + "friendly_name": "Bar RGB", + "supported_color_modes": ["rgb", "brightness"], + }, + }, + { + "entity_id": "light.bar_rgbw", + "state": "on", + "attributes": { + "friendly_name": "Bar RGBW", + "supported_color_modes": ["rgbw", "brightness"], + }, + }, + { + "entity_id": "light.bar_cct", + "state": "on", + "attributes": { + "friendly_name": "Bar CCT", + "supported_color_modes": ["color_temp", "brightness"], + }, + }, + { + "entity_id": "light.test", + "state": "off", + "attributes": { + "friendly_name": "Test light", + "supported_color_modes": ["brightness"], + }, + }, + { + "entity_id": "switch.test", + "state": "off", + "attributes": { + "friendly_name": "Test switch", + }, + }, + { + "entity_id": "input_boolean.test", + "state": "off", + "attributes": { + "friendly_name": "Test bool", + }, + }, + { + "entity_id": "scene.test", + "state": "scening", + "attributes": { + "friendly_name": "Test scene", + }, + }, + { + "entity_id": "automation.test", + "state": "on", + "attributes": { + "friendly_name": "Test automation", + }, + }, + ] + self.connection_error: Exception | None = None + self.dispatch_error: Exception | None = None + + async def get_api_root(self, base_url: str, token: str) -> dict[str, object]: + if self.connection_error is not None: + raise self.connection_error + return {"message": "API running."} + + async def get_config(self, base_url: str, token: str) -> dict[str, object]: + if self.connection_error is not None: + raise self.connection_error + return {"version": "2026.7.0"} + + async def list_entities(self, base_url: str, token: str) -> list[dict[str, object]]: + if self.connection_error is not None: + raise self.connection_error + return self.list_payload + + async def call_service( + self, + base_url: str, + token: str, + domain: str, + service: str, + data: dict[str, object], + ) -> dict[str, object]: + if self.dispatch_error is not None: + raise self.dispatch_error + self.calls.append((base_url, token, f"{domain}.{service}", data)) + return {"ok": True} + + +def build_service(adapter: FakeHomeAssistantAdapter) -> HomeAssistantService: + engine = DmxEngine(TelemetryService()) + service = HomeAssistantService(engine, adapter=adapter) + service._config = { + "enabled": True, + "base_url": "http://ha.local:8123", + "default_universe": 10, + } + service._token = "test-token" + return service + + +@pytest.mark.asyncio +async def test_home_assistant_connection_reports_version_and_auth() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + + result = await service.test_connection() + + assert result["reachable"] is True + assert result["auth_ok"] is True + assert result["ha_version"] == "2026.7.0" + config = await service.get_config() + assert config["has_token"] is True + assert config["token_mask"] == "********" + + +@pytest.mark.asyncio +async def test_home_assistant_rgb_mapping_reads_virtual_universe_10() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + frame.set_channel(1, 255, "scene") + frame.set_channel(2, 200, "scene") + frame.set_channel(3, 100, "scene") + frame.set_channel(4, 50, "scene") + service.engine.current_frames = {1: DmxFrame(universe=1), 10: frame} + await service.list_entities() + service._mappings = [ + service._normalize_mapping( + { + "id": 1, + "name": "Bar RGB", + "universe": 10, + "start_address": 1, + "fixture_type": "rgb", + "entity_id": "light.bar_rgb", + "rate_limit_hz": 5, + "deadband": 1, + "fade_ms": 150, + "enabled": True, + "master_dimmer": True, + } + ) + ] + + await service.dispatch_once() + + assert len(adapter.calls) == 1 + _base_url, _token, service_name, data = adapter.calls[0] + assert service_name == "light.turn_on" + assert data["entity_id"] == "light.bar_rgb" + assert data["brightness"] == 255 + assert data["rgb_color"] == [200, 100, 50] + + +@pytest.mark.asyncio +async def test_home_assistant_rgbw_mapping_at_channel_512_uses_last_channel() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + frame.set_channel(508, 255, "scene") + frame.set_channel(509, 10, "scene") + frame.set_channel(510, 20, "scene") + frame.set_channel(511, 30, "scene") + frame.set_channel(512, 40, "scene") + service.engine.current_frames = {10: frame} + await service.list_entities() + service._mappings = [ + service._normalize_mapping( + { + "id": 2, + "name": "Bar RGBW", + "universe": 10, + "start_address": 508, + "fixture_type": "rgbw", + "entity_id": "light.bar_rgbw", + "rate_limit_hz": 5, + "deadband": 1, + "fade_ms": 100, + "enabled": True, + "master_dimmer": True, + } + ) + ] + + await service.dispatch_once() + + assert adapter.calls[0][2] == "light.turn_on" + assert adapter.calls[0][3]["rgbw_color"] == [10, 20, 30, 40] + + +@pytest.mark.asyncio +async def test_home_assistant_rate_limit_prevents_duplicate_spam() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + frame.set_channel(1, 180, "scene") + service.engine.current_frames = {10: frame} + await service.list_entities() + service._mappings = [ + service._normalize_mapping( + { + "id": 3, + "name": "Dimmer", + "universe": 10, + "start_address": 1, + "fixture_type": "dimmer", + "entity_id": "light.bar_rgb", + "rate_limit_hz": 5, + "deadband": 2, + "fade_ms": 0, + "enabled": True, + "master_dimmer": False, + } + ) + ] + + await service.dispatch_once() + await service.dispatch_once() + + assert len(adapter.calls) == 1 + + +@pytest.mark.asyncio +async def test_scene_mapping_uses_rising_edge_only() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + service.engine.current_frames = {10: frame} + service._mappings = [ + service._normalize_mapping( + { + "id": 4, + "name": "Party scene", + "universe": 10, + "start_address": 6, + "fixture_type": "scene", + "entity_id": "scene.party_mode", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + await service.dispatch_once() + frame.set_channel(6, 140, "scene") + await service.dispatch_once() + await service.dispatch_once() + + assert len(adapter.calls) == 1 + assert adapter.calls[0][2] == "scene.turn_on" + + +@pytest.mark.asyncio +async def test_switch_mapping_uses_hysteresis() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + service.engine.current_frames = {10: frame} + service._mappings = [ + service._normalize_mapping( + { + "id": 5, + "name": "Switch", + "universe": 10, + "start_address": 50, + "fixture_type": "switch", + "entity_id": "switch.party_relay", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + frame.set_channel(50, 150, "scene") + await service.dispatch_once() + frame.set_channel(50, 130, "scene") + await service.dispatch_once() + frame.set_channel(50, 110, "scene") + await service.dispatch_once() + + assert [call[2] for call in adapter.calls] == ["switch.turn_on", "switch.turn_off"] + + +@pytest.mark.asyncio +async def test_switch_fixture_routes_light_entity_by_entity_domain() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + service.engine.current_frames = {10: frame} + service._mappings = [ + service._normalize_mapping( + { + "id": 10, + "name": "Light as switch", + "universe": 10, + "start_address": 20, + "fixture_type": "switch", + "entity_id": "light.test", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + frame.set_channel(20, 150, "scene") + await service.dispatch_once() + frame.set_channel(20, 110, "scene") + await service.dispatch_once() + + mapping = (await service.list_mappings())["items"][0] + assert [call[2] for call in adapter.calls] == ["light.turn_on", "light.turn_off"] + assert mapping["last_sent_summary"] == "light.turn_off light.test" + + +@pytest.mark.asyncio +async def test_switch_fixture_routes_switch_entity_by_entity_domain() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + service.engine.current_frames = {10: frame} + service._mappings = [ + service._normalize_mapping( + { + "id": 11, + "name": "Switch domain", + "universe": 10, + "start_address": 21, + "fixture_type": "switch", + "entity_id": "switch.test", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + frame.set_channel(21, 150, "scene") + await service.dispatch_once() + frame.set_channel(21, 110, "scene") + await service.dispatch_once() + + assert [call[2] for call in adapter.calls] == ["switch.turn_on", "switch.turn_off"] + + +@pytest.mark.asyncio +async def test_dimmer_fixture_routes_light_entity_with_brightness() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + frame.set_channel(22, 180, "scene") + service.engine.current_frames = {10: frame} + await service.list_entities() + service._mappings = [ + service._normalize_mapping( + { + "id": 12, + "name": "Dimmer light", + "universe": 10, + "start_address": 22, + "fixture_type": "dimmer", + "entity_id": "light.test", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + await service.dispatch_once() + + assert adapter.calls[0][2] == "light.turn_on" + assert adapter.calls[0][3]["brightness"] == 180 + + +@pytest.mark.asyncio +async def test_scene_fixture_routes_scene_entity() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + service.engine.current_frames = {10: frame} + service._mappings = [ + service._normalize_mapping( + { + "id": 13, + "name": "Scene test", + "universe": 10, + "start_address": 23, + "fixture_type": "scene", + "entity_id": "scene.test", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + frame.set_channel(23, 200, "scene") + await service.dispatch_once() + + assert adapter.calls[0][2] == "scene.turn_on" + + +@pytest.mark.asyncio +async def test_automation_fixture_routes_automation_entity() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + service.engine.current_frames = {10: frame} + service._mappings = [ + service._normalize_mapping( + { + "id": 14, + "name": "Automation test", + "universe": 10, + "start_address": 24, + "fixture_type": "automation", + "entity_id": "automation.test", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + frame.set_channel(24, 200, "scene") + await service.dispatch_once() + + assert adapter.calls[0][2] == "automation.trigger" + + +@pytest.mark.asyncio +async def test_test_mapping_uses_same_entity_domain_routing() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + await service.list_entities() + service._mappings = [ + service._normalize_mapping( + { + "id": 15, + "name": "Test mapping light switch", + "universe": 10, + "start_address": 25, + "fixture_type": "switch", + "entity_id": "light.test", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + await service.test_mapping(15) + + assert adapter.calls[0][2] == "light.turn_on" + + +@pytest.mark.asyncio +async def test_test_mapping_resyncs_back_to_live_dmx() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + frame.set_channel(1, 120, "scene") + service.engine.current_frames = {10: frame} + await service.list_entities() + service._mappings = [ + service._normalize_mapping( + { + "id": 6, + "name": "Dimmer", + "universe": 10, + "start_address": 1, + "fixture_type": "dimmer", + "entity_id": "light.bar_rgb", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + await service.test_mapping(6) + await service.dispatch_once() + + assert adapter.calls[0][3]["brightness"] == 255 + assert adapter.calls[-1][3]["brightness"] == 120 + + +@pytest.mark.asyncio +async def test_timeout_error_is_tracked_without_blocking() -> None: + adapter = FakeHomeAssistantAdapter() + adapter.dispatch_error = httpx.ReadTimeout("timeout") + service = build_service(adapter) + frame = DmxFrame(universe=10) + frame.set_channel(1, 180, "scene") + service.engine.current_frames = {10: frame} + await service.list_entities() + service._mappings = [ + service._normalize_mapping( + { + "id": 7, + "name": "Dimmer", + "universe": 10, + "start_address": 1, + "fixture_type": "dimmer", + "entity_id": "light.bar_rgb", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + await service.dispatch_once() + + mapping = (await service.list_mappings())["items"][0] + assert mapping["last_error"] == "Timeout ved kald til Home Assistant" + + +@pytest.mark.asyncio +async def test_401_is_reported_from_connection_test() -> None: + adapter = FakeHomeAssistantAdapter() + request = httpx.Request("GET", "http://ha.local:8123/api/") + response = httpx.Response(401, request=request) + adapter.connection_error = httpx.HTTPStatusError("401", request=request, response=response) + service = build_service(adapter) + + result = await service.test_connection() + + assert result["reachable"] is True + assert result["auth_ok"] is False + assert result["last_error"] == "HTTP 401 fra Home Assistant" + + +@pytest.mark.asyncio +async def test_404_entity_not_found_is_tracked() -> None: + adapter = FakeHomeAssistantAdapter() + request = httpx.Request("POST", "http://ha.local:8123/api/services/light/turn_on") + response = httpx.Response(404, request=request) + adapter.dispatch_error = httpx.HTTPStatusError("404", request=request, response=response) + service = build_service(adapter) + frame = DmxFrame(universe=10) + frame.set_channel(1, 180, "scene") + service.engine.current_frames = {10: frame} + await service.list_entities() + service._mappings = [ + service._normalize_mapping( + { + "id": 8, + "name": "Dimmer", + "universe": 10, + "start_address": 1, + "fixture_type": "dimmer", + "entity_id": "light.bar_rgb", + "enabled": True, + "master_dimmer": False, + } + ) + ] + + await service.dispatch_once() + + mapping = (await service.list_mappings())["items"][0] + assert mapping["last_error"] == "HTTP 404 fra Home Assistant" + + +@pytest.mark.asyncio +async def test_home_assistant_unavailable_is_reported() -> None: + adapter = FakeHomeAssistantAdapter() + adapter.connection_error = httpx.ConnectError("offline") + service = build_service(adapter) + + result = await service.test_connection() + + assert result["reachable"] is False + assert result["auth_ok"] is False + assert result["last_error"] == "Home Assistant kunne ikke kontaktes" + + +@pytest.mark.asyncio +async def test_persistence_survives_service_reload() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + await service.save_config( + { + "enabled": True, + "base_url": "http://ha.local:8123", + "token": "persistent-token", + "default_universe": 10, + } + ) + await service.create_mapping( + { + "name": "Persisted RGB", + "universe": 10, + "start_address": 1, + "fixture_type": "rgb", + "entity_id": "light.bar_rgb", + "enabled": True, + "master_dimmer": True, + } + ) + + reloaded = HomeAssistantService(service.engine, adapter=adapter) + await reloaded._load() + + config = await reloaded.get_config() + mappings = await reloaded.list_mappings() + assert config["base_url"] == "http://ha.local:8123" + assert config["has_token"] is True + assert mappings["items"][0]["entity_id"] == "light.bar_rgb" + + +@pytest.mark.asyncio +async def test_slow_dispatch_drops_intermediate_frames_and_keeps_latest() -> None: + adapter = FakeHomeAssistantAdapter() + service = build_service(adapter) + frame = DmxFrame(universe=10) + frame.set_channel(1, 50, "scene") + service.engine.current_frames = {10: frame} + await service.list_entities() + service._mappings = [ + service._normalize_mapping( + { + "id": 9, + "name": "Dimmer", + "universe": 10, + "start_address": 1, + "fixture_type": "dimmer", + "entity_id": "light.bar_rgb", + "enabled": True, + "master_dimmer": False, + "deadband": 1, + "rate_limit_hz": 30, + } + ) + ] + + first_call = asyncio.Event() + release_call = asyncio.Event() + + async def slow_call( + base_url: str, + token: str, + domain: str, + service_name: str, + data: dict[str, object], + ) -> dict[str, object]: + adapter.calls.append((base_url, token, f"{domain}.{service_name}", data)) + first_call.set() + await release_call.wait() + return {"ok": True} + + adapter.call_service = slow_call # type: ignore[method-assign] + + dispatch_task = asyncio.create_task(service._dispatch_enabled_mappings()) + await first_call.wait() + frame.set_channel(1, 120, "scene") + await service._dispatch_enabled_mappings() + frame.set_channel(1, 220, "scene") + await service._dispatch_enabled_mappings() + release_call.set() + await dispatch_task + await service._drain_mapping_tasks() + + assert adapter.calls[0][3]["brightness"] == 50 + assert adapter.calls[-1][3]["brightness"] == 220 + assert len(adapter.calls) == 2 diff --git a/backend/tests/test_install_scripts.py b/backend/tests/test_install_scripts.py new file mode 100644 index 0000000..da14b5f --- /dev/null +++ b/backend/tests/test_install_scripts.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS_DIR = ROOT / "scripts" + + +def read_script(name: str) -> str: + return (SCRIPTS_DIR / name).read_text(encoding="utf-8") + + +def test_install_linux_script_exists_and_supports_required_flags() -> None: + script = read_script("install-linux.sh") + assert 'parse_common_args "$@"' in script + assert "verify_debian_13_amd64" in script + assert 'run_install_flow "Debian 13 amd64"' in script + + +def test_update_linux_script_exists_and_uses_common_arg_parser() -> None: + script = read_script("update-linux.sh") + assert 'parse_common_args "$@"' in script + assert "verify_debian_13_amd64" in script + assert 'run_update_flow "Debian 13 amd64"' in script + + +def test_common_install_library_contains_required_linux_behaviour() -> None: + script = read_script("lib/install-common.sh") + assert "python3-venv" in script + assert "sqlite3" in script + assert "sudo" in script + assert "nodejs" in script + assert "npm" in script + assert "python3-ola" in script + assert "ola-python" in script + assert "olad" in script + assert 'PNPM_REQUIRED_VERSION="10.28.2"' in script + assert "--system-site-packages" in script + assert "/opt/tuxdmx.new" in script + assert "/opt/tuxdmx" in script + assert "/var/lib/tuxdmx" in script + assert "/var/log/tuxdmx" in script + assert "/etc/tuxdmx" in script + assert "dialout,plugdev,audio" in script + assert "frontend/dist/index.html" in script + assert "pip\" check" in script + assert "apt-listchanges" in script + assert "debconf" in script + assert "Ignorerer kendt Debian pip check-advarsel" in script + assert "import greenlet" in script + assert "import sqlalchemy" in script + assert "import ola" in script + assert "backend/app/main.py" in script + assert "systemctl status \"$SYSTEMD_UNIT_NAME\" --no-pager --full" in script + assert "journalctl -u \"$SYSTEMD_UNIT_NAME\" -n 200 --no-pager" in script + assert "update-rc.d" in script + assert "systemctl is-enabled \"$OLA_SYSTEMD_UNIT\"" in script + assert "service \"$OLA_SERVICE_NAME\" restart" in script + assert "--warning=no-file-changed" in script + assert "--exclude=\"./tuxdmx.json.log\"" in script + assert "Kunne ikke oprette backup af datamappe." in script + assert "http://127.0.0.1:8000/api/v1/health" in script + assert "/etc/sudoers.d/tuxdmx-control" in script + assert "control-system.sh restart-service" in script + assert "control-system.sh reboot-host" in script + assert "restore_rollbacks" in script + + +def test_pi_and_linux_installers_share_common_library() -> None: + for name in ( + "install-pi.sh", + "install-linux.sh", + "update-pi.sh", + "update-linux.sh", + ): + script = read_script(name) + assert 'source "$SCRIPT_DIR/lib/install-common.sh"' in script + + +def test_systemd_unit_contains_required_production_fields() -> None: + unit = (ROOT / "systemd" / "tuxdmx.service").read_text(encoding="utf-8") + assert "WorkingDirectory=/opt/tuxdmx" in unit + assert "After=network-online.target olad.service" in unit + assert "Wants=network-online.target olad.service" in unit + assert "Restart=on-failure" in unit + assert "UMask=0027" in unit + + +def test_alembic_ini_uses_path_relative_to_config_file() -> None: + alembic_ini = (ROOT / "backend" / "alembic.ini").read_text(encoding="utf-8") + assert "script_location = %(here)s/alembic" in alembic_ini + + +def test_package_manager_and_requirements_are_locked() -> None: + package_json = json.loads((ROOT / "package.json").read_text(encoding="utf-8")) + assert package_json["packageManager"] == "pnpm@10.28.2" + assert package_json["engines"]["node"] == "20.x" + assert package_json["engines"]["pnpm"] == "10.28.2" + + requirements = (ROOT / "requirements.lock").read_text(encoding="utf-8") + assert "greenlet==" in requirements + assert "sqlalchemy==" in requirements + + +def test_shell_scripts_parse_with_bash_when_available() -> None: + bash = shutil.which("bash") + if bash is None: + return + if "system32\\bash.exe" in bash.lower(): + return + + for script_name in ( + "scripts/install-pi.sh", + "scripts/update-pi.sh", + "scripts/install-linux.sh", + "scripts/update-linux.sh", + "scripts/uninstall-linux.sh", + "scripts/lib/install-common.sh", + ): + subprocess.run( + [bash, "-n", str(ROOT / script_name)], + check=True, + cwd=ROOT, + ) + + +def test_shellcheck_when_available() -> None: + shellcheck = shutil.which("shellcheck") + if shellcheck is None: + return + + subprocess.run( + [ + shellcheck, + str(ROOT / "scripts/install-pi.sh"), + str(ROOT / "scripts/update-pi.sh"), + str(ROOT / "scripts/install-linux.sh"), + str(ROOT / "scripts/update-linux.sh"), + str(ROOT / "scripts/lib/install-common.sh"), + ], + check=True, + cwd=ROOT, + ) + + +def test_pip_check_and_greenlet_import() -> None: + subprocess.run([sys.executable, "-m", "pip", "check"], check=True, cwd=ROOT) + subprocess.run( + [sys.executable, "-c", "import greenlet; print(greenlet.__version__)"], + check=True, + cwd=ROOT, + ) diff --git a/backend/tests/test_midi_api.py b/backend/tests/test_midi_api.py new file mode 100644 index 0000000..45dbed9 --- /dev/null +++ b/backend/tests/test_midi_api.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from app.core.dependencies import app_state +from app.main import app + + +def _create_token(client: TestClient, bridge_id: str | None = None) -> str: + response = client.post( + "/api/v1/integrations/midi/tokens", + json={ + "label": f"pytest-{uuid4().hex[:8]}", + "bridge_id": bridge_id, + "scopes": ["midi:connect", "midi:events", "midi:heartbeat"], + }, + ) + assert response.status_code == 200 + return response.json()["token"] + + +def _create_scene(client: TestClient, slug: str) -> int: + response = client.post( + "/api/v1/scenes", + json={ + "name": slug, + "slug": slug, + "priority": 25, + "values": [{"channel": 10, "value": 255, "precedence": "htp", "source": "scene"}], + "targets": [], + }, + ) + assert response.status_code == 200 + return int(response.json()["id"]) + + +def test_midi_heartbeat_updates_bridge_status() -> None: + with TestClient(app) as client: + token = _create_token(client, bridge_id="bridge-heartbeat") + heartbeat = client.post( + "/api/v1/integrations/midi/bridge/heartbeat", + headers={"Authorization": f"Bearer {token}"}, + json={ + "type": "heartbeat", + "protocol_version": 1, + "bridge_id": "bridge-heartbeat", + "device": "USB MIDI Test", + "timestamp": "2026-07-24T20:30:00+00:00", + }, + ) + assert heartbeat.status_code == 200 + bridges = client.get("/api/v1/integrations/midi/bridges") + assert bridges.status_code == 200 + item = next(entry for entry in bridges.json()["items"] if entry["bridge_id"] == "bridge-heartbeat") + assert item["device_name"] == "USB MIDI Test" + assert item["online"] is True + assert item["last_heartbeat_at"] is not None + + +def test_midi_event_can_activate_scene_through_mapping() -> None: + slug = f"midi-scene-{uuid4().hex[:8]}" + with TestClient(app) as client: + scene_id = _create_scene(client, slug) + token = _create_token(client, bridge_id="bridge-scenes") + mapping = client.post( + "/api/v1/integrations/midi/mappings", + json={ + "name": "Scene trigger", + "enabled": True, + "bridge_id": "bridge-scenes", + "device_name": "USB*", + "message_type": "note_on", + "channel": 0, + "number": 36, + "action": "activate_scene", + "target_type": "scene", + "target_id": slug, + "mode": "trigger", + "minimum_value": 1, + "maximum_value": 127, + }, + ) + assert mapping.status_code == 200 + + event = client.post( + "/api/v1/integrations/midi/bridge/events", + headers={"Authorization": f"Bearer {token}"}, + json={ + "type": "midi_event", + "protocol_version": 1, + "bridge_id": "bridge-scenes", + "device": "USB MIDI Controller", + "timestamp": "2026-07-24T20:30:00+00:00", + "message": {"type": "note_on", "channel": 0, "number": 36, "value": 127}, + }, + ) + assert event.status_code == 200 + assert event.json()["executed"] == 1 + assert f"scene:{slug}" in app_state.engine.layers + + client.delete(f"/api/v1/integrations/midi/mappings/{mapping.json()['id']}") + client.delete(f"/api/v1/scenes/{scene_id}") + + +def test_midi_flash_hold_applies_and_releases_scene_layer() -> None: + slug = f"midi-flash-{uuid4().hex[:8]}" + with TestClient(app) as client: + scene_id = _create_scene(client, slug) + token = _create_token(client, bridge_id="bridge-flash") + mapping_response = client.post( + "/api/v1/integrations/midi/mappings", + json={ + "name": "Flash scene", + "enabled": True, + "bridge_id": "bridge-flash", + "device_name": "*", + "message_type": "note_on", + "channel": 0, + "number": 40, + "action": "flash_scene", + "target_type": "scene", + "target_id": slug, + "mode": "hold", + "minimum_value": 1, + "maximum_value": 127, + }, + ) + assert mapping_response.status_code == 200 + mapping_id = mapping_response.json()["id"] + layer_name = f"midi:flash:{mapping_id}:{slug}" + + note_on = client.post( + "/api/v1/integrations/midi/bridge/events", + headers={"Authorization": f"Bearer {token}"}, + json={ + "type": "midi_event", + "protocol_version": 1, + "bridge_id": "bridge-flash", + "device": "USB MIDI Controller", + "timestamp": "2026-07-24T20:30:01+00:00", + "message": {"type": "note_on", "channel": 0, "number": 40, "value": 127}, + }, + ) + assert note_on.status_code == 200 + assert layer_name in app_state.engine.layers + + note_off = client.post( + "/api/v1/integrations/midi/bridge/events", + headers={"Authorization": f"Bearer {token}"}, + json={ + "type": "midi_event", + "protocol_version": 1, + "bridge_id": "bridge-flash", + "device": "USB MIDI Controller", + "timestamp": "2026-07-24T20:30:02+00:00", + "message": {"type": "note_off", "channel": 0, "number": 40, "value": 0}, + }, + ) + assert note_off.status_code == 200 + assert layer_name not in app_state.engine.layers + + client.delete(f"/api/v1/integrations/midi/mappings/{mapping_id}") + client.delete(f"/api/v1/scenes/{scene_id}") + + +def test_midi_continuous_master_and_scene_intensity_are_scaled() -> None: + slug = f"midi-intensity-{uuid4().hex[:8]}" + with TestClient(app) as client: + scene_id = _create_scene(client, slug) + token = _create_token(client, bridge_id="bridge-cc") + master_mapping = client.post( + "/api/v1/integrations/midi/mappings", + json={ + "name": "Master dimmer", + "enabled": True, + "bridge_id": "bridge-cc", + "device_name": "*", + "message_type": "control_change", + "channel": 0, + "number": 14, + "action": "set_master_dimmer", + "target_type": "global", + "target_id": None, + "mode": "continuous", + "minimum_value": 0, + "maximum_value": 127, + }, + ) + assert master_mapping.status_code == 200 + + intensity_mapping = client.post( + "/api/v1/integrations/midi/mappings", + json={ + "name": "Scene intensity", + "enabled": True, + "bridge_id": "bridge-cc", + "device_name": "*", + "message_type": "control_change", + "channel": 0, + "number": 15, + "action": "set_scene_intensity", + "target_type": "scene", + "target_id": slug, + "mode": "continuous", + "minimum_value": 0, + "maximum_value": 127, + }, + ) + assert intensity_mapping.status_code == 200 + + master_event = client.post( + "/api/v1/integrations/midi/bridge/events", + headers={"Authorization": f"Bearer {token}"}, + json={ + "type": "midi_event", + "protocol_version": 1, + "bridge_id": "bridge-cc", + "device": "USB MIDI Controller", + "timestamp": "2026-07-24T20:31:00+00:00", + "message": {"type": "control_change", "channel": 0, "number": 14, "value": 64}, + }, + ) + assert master_event.status_code == 200 + assert 120 <= app_state.engine.master <= 132 + + intensity_event = client.post( + "/api/v1/integrations/midi/bridge/events", + headers={"Authorization": f"Bearer {token}"}, + json={ + "type": "midi_event", + "protocol_version": 1, + "bridge_id": "bridge-cc", + "device": "USB MIDI Controller", + "timestamp": "2026-07-24T20:31:01+00:00", + "message": {"type": "control_change", "channel": 0, "number": 15, "value": 64}, + }, + ) + assert intensity_event.status_code == 200 + layer_name = f"midi:intensity:{intensity_mapping.json()['id']}:{slug}" + layer = app_state.engine.layers[layer_name] + assert 120 <= layer.values_by_universe[1][10] <= 132 + + client.delete(f"/api/v1/integrations/midi/mappings/{master_mapping.json()['id']}") + client.delete(f"/api/v1/integrations/midi/mappings/{intensity_mapping.json()['id']}") + client.delete(f"/api/v1/scenes/{scene_id}") + + +def test_midi_learn_captures_next_event_without_triggering_existing_mapping() -> None: + slug = f"midi-learn-{uuid4().hex[:8]}" + with TestClient(app) as client: + scene_id = _create_scene(client, slug) + token = _create_token(client, bridge_id="bridge-learn") + mapping = client.post( + "/api/v1/integrations/midi/mappings", + json={ + "name": "Learn target", + "enabled": True, + "bridge_id": "bridge-learn", + "device_name": "*", + "message_type": "note_on", + "channel": 0, + "number": 41, + "action": "activate_scene", + "target_type": "scene", + "target_id": slug, + "mode": "trigger", + "minimum_value": 1, + "maximum_value": 127, + }, + ) + assert mapping.status_code == 200 + + start = client.post( + "/api/v1/integrations/midi/learn/start", + json={"timeout_seconds": 15, "allow_passthrough": False}, + ) + assert start.status_code == 200 + assert start.json()["active"] is True + + event = client.post( + "/api/v1/integrations/midi/bridge/events", + headers={"Authorization": f"Bearer {token}"}, + json={ + "type": "midi_event", + "protocol_version": 1, + "bridge_id": "bridge-learn", + "device": "USB MIDI Controller", + "timestamp": "2026-07-24T20:32:00+00:00", + "message": {"type": "note_on", "channel": 0, "number": 41, "value": 127}, + }, + ) + assert event.status_code == 200 + assert event.json()["captured_for_learning"] is True + assert f"scene:{slug}" not in app_state.engine.layers + + state = client.get("/api/v1/integrations/midi/learn") + assert state.status_code == 200 + assert state.json()["active"] is False + assert state.json()["captured_event"]["message"]["number"] == 41 + + client.delete(f"/api/v1/integrations/midi/mappings/{mapping.json()['id']}") + client.delete(f"/api/v1/scenes/{scene_id}") + + +def test_midi_bridge_token_can_be_restricted_to_bridge_id() -> None: + with TestClient(app) as client: + token = _create_token(client, bridge_id="bridge-locked") + response = client.post( + "/api/v1/integrations/midi/bridge/events", + headers={"Authorization": f"Bearer {token}"}, + json={ + "type": "midi_event", + "protocol_version": 1, + "bridge_id": "wrong-bridge", + "device": "USB MIDI Controller", + "timestamp": "2026-07-24T20:33:00+00:00", + "message": {"type": "note_on", "channel": 0, "number": 50, "value": 127}, + }, + ) + assert response.status_code == 401 diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..bf6b95e --- /dev/null +++ b/backend/tests/test_migrations.py @@ -0,0 +1,45 @@ +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path + + +def test_alembic_upgrade_from_empty_database(tmp_path: Path) -> None: + database_path = tmp_path / "audit-migrations.db" + env = os.environ.copy() + env["TUXDMX_DATABASE_URL"] = f"sqlite:///{database_path.as_posix()}" + + result = subprocess.run( + [ + sys.executable, + "-m", + "alembic", + "-c", + "backend/alembic.ini", + "upgrade", + "head", + ], + cwd=Path(__file__).resolve().parents[2], + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + with sqlite3.connect(database_path) as connection: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + assert "alembic_version" in tables + assert "users" in tables + assert "fixture_sources" in tables + assert "fixture_definitions" in tables + assert "fixture_instances" in tables + assert "midi_bridges" in tables + assert "midi_bridge_tokens" in tables + assert "midi_mappings" in tables diff --git a/backend/tests/test_ofl_client.py b/backend/tests/test_ofl_client.py new file mode 100644 index 0000000..9527e30 --- /dev/null +++ b/backend/tests/test_ofl_client.py @@ -0,0 +1,37 @@ +import asyncio + +import httpx + +from app.fixtures.ofl import OflClient + + +def test_fetch_fixture_uses_json_endpoint(monkeypatch) -> None: + requested_urls: list[str] = [] + + class FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return {"name": "LED Bar-3 HCL Bar", "modes": []} + + class FakeAsyncClient: + def __init__(self, *args, **kwargs) -> None: + pass + + async def __aenter__(self) -> "FakeAsyncClient": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def get(self, url: str) -> FakeResponse: + requested_urls.append(url) + return FakeResponse() + + monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient) + + payload = asyncio.run(OflClient().fetch_fixture("eurolite", "led-bar-3-hcl-bar")) + + assert payload["name"] == "LED Bar-3 HCL Bar" + assert requested_urls == ["https://open-fixture-library.org/eurolite/led-bar-3-hcl-bar.json"] diff --git a/backend/tests/test_ola_backend.py b/backend/tests/test_ola_backend.py new file mode 100644 index 0000000..f30d352 --- /dev/null +++ b/backend/tests/test_ola_backend.py @@ -0,0 +1,141 @@ +import pytest +from app.dmx.backends import FakeOlaClientAdapter, OlaDmxBackend, PythonOlaClientAdapter +from app.dmx.frame import DmxFrame + + +@pytest.mark.asyncio +async def test_ola_backend_sends_full_frame_and_updates_status() -> None: + adapter = FakeOlaClientAdapter() + backend = OlaDmxBackend( + universe=7, + output_port="usb-dmx-1", + adapter_factory=lambda: adapter, + ) + frame = DmxFrame(universe=7) + frame.set_channel(1, 255, "scene") + frame.set_channel(512, 64, "scene") + + await backend.send_frame(frame) + + status = backend.get_status() + assert status.connected is True + assert status.degraded is False + assert status.frames_sent == 1 + assert status.send_errors == 0 + assert status.last_successful_frame is not None + assert status.selected_universe == 7 + assert status.selected_output_port == "usb-dmx-1" + assert adapter.open_calls == 1 + assert len(adapter.sent_frames) == 1 + sent_universe, values = adapter.sent_frames[0] + assert sent_universe == 7 + assert len(values) == 512 + assert values[0] == 255 + assert values[511] == 64 + + +@pytest.mark.asyncio +async def test_ola_backend_reconnects_after_send_failure() -> None: + created_adapters: list[FakeOlaClientAdapter] = [] + + def factory() -> FakeOlaClientAdapter: + adapter = FakeOlaClientAdapter( + send_results=[RuntimeError("olad unavailable")] if not created_adapters else [], + ) + created_adapters.append(adapter) + return adapter + + backend = OlaDmxBackend( + universe=1, + output_port="usb-dmx-1", + adapter_factory=factory, + ) + frame = DmxFrame() + frame.set_channel(1, 128, "scene") + + with pytest.raises(RuntimeError, match="olad unavailable"): + await backend.send_frame(frame) + + failed_status = backend.get_status() + assert failed_status.connected is False + assert failed_status.degraded is True + assert failed_status.send_errors == 1 + assert failed_status.frames_sent == 0 + + await backend.send_frame(frame) + + recovered_status = backend.get_status() + assert recovered_status.connected is True + assert recovered_status.degraded is False + assert recovered_status.reconnect_count == 1 + assert recovered_status.frames_sent == 1 + assert recovered_status.send_errors == 1 + assert len(created_adapters) == 2 + assert created_adapters[0].close_calls == 1 + assert created_adapters[1].sent_frames[0][1][0] == 128 + + +@pytest.mark.asyncio +async def test_ola_backend_does_not_send_random_black_frame_on_reconnect() -> None: + created_adapters: list[FakeOlaClientAdapter] = [] + + def factory() -> FakeOlaClientAdapter: + adapter = FakeOlaClientAdapter( + send_results=[RuntimeError("send failed")] if not created_adapters else [], + ) + created_adapters.append(adapter) + return adapter + + backend = OlaDmxBackend( + universe=3, + output_port="usb-dmx-2", + adapter_factory=factory, + ) + failed_frame = DmxFrame(universe=3) + failed_frame.set_channel(1, 10, "effect") + recovered_frame = DmxFrame(universe=3) + recovered_frame.set_channel(1, 200, "effect") + recovered_frame.set_channel(2, 150, "effect") + + with pytest.raises(RuntimeError, match="send failed"): + await backend.send_frame(failed_frame) + + await backend.send_frame(recovered_frame) + + assert created_adapters[0].sent_frames[0][1][0] == 10 + assert created_adapters[1].sent_frames[0][0] == 3 + assert created_adapters[1].sent_frames[0][1] == recovered_frame.values + assert any(value != 0 for value in created_adapters[1].sent_frames[0][1]) + + +def test_python_ola_adapter_uses_buffer_with_tobytes() -> None: + captured: dict[str, object] = {} + + class FakeClient: + def SendDmx(self, universe: int, payload: object, callback: object) -> None: + captured["universe"] = universe + captured["payload_type"] = type(payload).__name__ + captured["has_tobytes"] = hasattr(payload, "tobytes") + callback(True) + + class FakeWrapper: + def __init__(self) -> None: + self._client = FakeClient() + + def Client(self) -> FakeClient: + return self._client + + def Run(self) -> None: + return + + def Stop(self) -> None: + return + + adapter = PythonOlaClientAdapter(timeout_s=0.1) + adapter._wrapper_class = FakeWrapper + + adapter.send_frame(1, [0, 255] + [0] * 510) + + assert captured["universe"] == 1 + assert captured["payload_type"] == "array" + assert captured["has_tobytes"] is True diff --git a/backend/tests/test_production_runtime.py b/backend/tests/test_production_runtime.py new file mode 100644 index 0000000..6a2328c --- /dev/null +++ b/backend/tests/test_production_runtime.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_app_imports_with_production_like_absolute_paths(tmp_path: Path) -> None: + frontend_dist = tmp_path / "frontend-dist" + data_dir = tmp_path / "data" + backup_dir = data_dir / "backups" + fixture_dir = data_dir / "fixtures" + diagnostics_dir = data_dir / "diagnostics" + uploads_dir = data_dir / "uploads" + log_dir = tmp_path / "logs" + frontend_dist.mkdir(parents=True) + data_dir.mkdir(parents=True) + backup_dir.mkdir() + fixture_dir.mkdir() + diagnostics_dir.mkdir() + uploads_dir.mkdir() + log_dir.mkdir() + (frontend_dist / "index.html").write_text( + "TuxDMX", + encoding="utf-8", + ) + + env = os.environ.copy() + env.update( + { + "PYTHONPATH": str(ROOT / "backend"), + "TUXDMX_FRONTEND_DIST": str(frontend_dist), + "TUXDMX_DATA_DIR": str(data_dir), + "TUXDMX_BACKUP_DIR": str(backup_dir), + "TUXDMX_FIXTURE_CACHE_DIR": str(fixture_dir), + "TUXDMX_DIAGNOSTICS_DIR": str(diagnostics_dir), + "TUXDMX_UPLOADS_DIR": str(uploads_dir), + "TUXDMX_LOG_DIR": str(log_dir), + "TUXDMX_DATABASE_URL": f"sqlite+aiosqlite:///{(data_dir / 'tuxdmx.db').as_posix()}", + } + ) + + subprocess.run( + [ + sys.executable, + "-c", + ( + "from app.core.config import get_settings; " + "get_settings.cache_clear(); " + "import app.main; " + "settings = get_settings(); " + "assert settings.frontend_dist.is_absolute(); " + "assert settings.data_dir.is_absolute(); " + "assert settings.backup_dir.is_absolute(); " + "assert settings.diagnostics_dir.is_absolute(); " + "assert settings.uploads_dir.is_absolute(); " + "print('ok')" + ), + ], + check=True, + cwd=ROOT, + env=env, + ) diff --git a/docs/MIDI_BRIDGE.md b/docs/MIDI_BRIDGE.md new file mode 100644 index 0000000..411d867 --- /dev/null +++ b/docs/MIDI_BRIDGE.md @@ -0,0 +1,124 @@ +# MIDI Bridge + +## Formål + +TuxDMX kan modtage normaliserede MIDI-events fra en ekstern Windows- eller Linux-maskine via en separat Python-bro i `tools/tuxdmx-midi-bridge/`. + +Bridgen læser kun MIDI og sender events videre til TuxDMX. Den genererer aldrig DMX direkte. + +## Eksisterende arkitektur før ændringen + +- Sceneaktivering fandtes allerede via `POST /api/v1/scenes/{scene_slug}/activate` +- Scenerelease fandtes allerede via `POST /api/v1/scenes/{scene_slug}/release` +- Blackout fandtes allerede via `POST /api/v1/dmx/blackout` og `POST /api/v1/dmx/release-blackout` +- Effektstart/-stop fandtes allerede via `POST /api/v1/effects/{effect_slug}/trigger` og `POST /api/v1/effects/{effect_slug}/stop` +- Live status blev sendt ud via WebSocket på `/ws/live` +- Session/CSRF og API-tokenmodeller fandtes i backend, men MIDI-broen havde ingen eksisterende transport eller mappingmodel + +## Nye backend-endpoints + +### Admin / WebUI + +- `GET /api/v1/integrations/midi/bridges` +- `GET /api/v1/integrations/midi/mappings` +- `POST /api/v1/integrations/midi/mappings` +- `PUT /api/v1/integrations/midi/mappings/{mapping_id}` +- `DELETE /api/v1/integrations/midi/mappings/{mapping_id}` +- `POST /api/v1/integrations/midi/mappings/{mapping_id}/test` +- `GET /api/v1/integrations/midi/tokens` +- `POST /api/v1/integrations/midi/tokens` +- `DELETE /api/v1/integrations/midi/tokens/{token_id}` +- `GET /api/v1/integrations/midi/learn` +- `POST /api/v1/integrations/midi/learn/start` +- `POST /api/v1/integrations/midi/learn/cancel` + +### Bridge transport + +- `POST /api/v1/integrations/midi/bridge/heartbeat` +- `POST /api/v1/integrations/midi/bridge/events` + +Bridge-endpoints kræver `Authorization: Bearer `. + +## Datamodel + +### `midi_bridges` + +- `bridge_id` +- `device_name` +- `ip_address` +- `protocol_version` +- `online` +- `last_heartbeat_at` +- `last_event_at` +- `last_error` +- `last_event` + +### `midi_bridge_tokens` + +- `label` +- `token_hash` +- `bridge_id` valgfri låsning til ét bridge-id +- `scopes` +- `last_used_at` +- `revoked_at` + +### `midi_mappings` + +- `name` +- `enabled` +- `bridge_id` +- `device_name` +- `message_type` +- `channel` +- `number` +- `action` +- `target_type` +- `target_id` +- `mode` +- `minimum_value` +- `maximum_value` + +## Understøttede actions + +- `activate_scene` +- `deactivate_scene` +- `toggle_scene` +- `flash_scene` +- `blackout_on` +- `blackout_off` +- `blackout_toggle` +- `set_master_dimmer` +- `set_scene_intensity` +- `start_effect` +- `stop_effect` + +## Modes + +- `trigger` +- `toggle` +- `hold` +- `flash` +- `continuous` + +## Sikkerhed + +- Tokens returneres kun i klartekst ved oprettelse +- Efter lagring vises kun `token_preview` +- Bridge-token logges ikke +- Protokolversion skal være `1` +- Bridge kan låses til specifikt `bridge_id` +- Ugyldige eller tilbagekaldte tokens afvises med `401` + +## Bridge-installation + +Se den fulde bridgepakke i: + +- `tools/tuxdmx-midi-bridge/README.md` +- `tools/tuxdmx-midi-bridge/config.example.yaml` +- `tools/tuxdmx-midi-bridge/tuxdmx-midi-bridge.service.example` + +## Kendte begrænsninger + +- Fysisk MIDI-hardware er ikke testet i denne workspace-session +- Bridge bruger HTTP, ikke indgående WebSocket, selv om UI stadig bruger WebSocket til live status +- `set_effect_speed` er ikke implementeret, fordi effektmotoren endnu ikke har et stabilt genanvendeligt speed-interface diff --git a/docs/system-manual/MANUAL.md b/docs/system-manual/MANUAL.md index 4057ff6..37153ef 100644 --- a/docs/system-manual/MANUAL.md +++ b/docs/system-manual/MANUAL.md @@ -13,10 +13,29 @@ Denne manual beskriver den nuvaerende WebUI og de vigtigste arbejdsgange. ## Vigtigt -- Screenshots i denne mappe er genereret lokalt i `mock`-tilstand den 23. juli 2026. +- Screenshots i denne mappe er genereret lokalt i `mock`-tilstand den 25. juli 2026. - De afspejler UI og flows, men er ikke bevis for fysisk hardwareafvikling. - Fysisk hardwaretest skal stadig vurderes separat paa target-maskinen. +## Navigation + +WebUI bruger nu en kompakt sidebar med grupperede undermenuer: + +- `Overblik` + Dashboard og Live Desk +- `Live-kontrol` + Scener, Effekter og BPM +- `Lysopsaetning` + Fixtures, DMX Patch og Placering +- `Integrationer` + MixItUp og MIDI +- `Overvaagning` + DMX monitor og Telemetry +- `System` + Backup og Indstillinger + +Kun den aktive sektion staar aaben ad gangen, saa UI ikke fylder unodigt meget i venstre side. + ## Arkitektur kort fortalt - `Universe 1` @@ -186,6 +205,19 @@ ALSA-input skal vaelges i `Settings`, og derefter bruges BPM-siden til selve dri Herfra kan du teste triggerflow og senere knytte stream-events til scener eller effekter. +## MIDI + +`MIDI` samler den separate MIDI-bridge og dens mappings. + +Herfra kan du: + +- se aktive bridges +- oprette og tilbagekalde bridge-tokens +- bruge `MIDI Learn` +- mappe knapper og faders til scener, effekter, blackout og master + +MIDI sender ikke DMX direkte. Den kalder backendens eksisterende scene-, effekt- og systemhandlinger. + ## DMX monitor `DMX monitor` viser DMX-aktivitet. @@ -269,6 +301,12 @@ Fra denne workspace: powershell -ExecutionPolicy Bypass -File .\scripts\sync-windows-deploy.ps1 ``` +Hvis du vil pushe dokumentation, screenshots og kode videre til TuxiNet Git: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\push-git-tuxinet.ps1 -InitIfNeeded -SetOrigin -StageAll -Commit -CommitMessage "Update docs and screenshots" -Push +``` + ### Linux target-host ```bash diff --git a/docs/system-manual/README.md b/docs/system-manual/README.md index 7df662e..171759a 100644 --- a/docs/system-manual/README.md +++ b/docs/system-manual/README.md @@ -13,8 +13,9 @@ Indhold: Vigtigt: -- Screenshots i denne mappe er genereret den 23. juli 2026 fra den lokale WebUI i `mock=1` mode. +- Screenshots i denne mappe er genereret den 25. juli 2026 fra den lokale WebUI i `mock=1` mode. - De viser den aktuelle UI-struktur og sideopbygning. +- De afspejler den kompakte, grupperede navigation med undermenuer. - De er ikke dokumentation for fysisk Raspberry Pi-, OLA-, USB-DMX- eller lys-hardwaretest. - Hardwareafgraensninger og manuel hardwarevalidering hoerer stadig hjemme i [HARDWARE-VALIDATION.md](../HARDWARE-VALIDATION.md). diff --git a/docs/system-manual/SCREENSHOTS.md b/docs/system-manual/SCREENSHOTS.md index 90388d9..c4607db 100644 --- a/docs/system-manual/SCREENSHOTS.md +++ b/docs/system-manual/SCREENSHOTS.md @@ -1,6 +1,6 @@ # Screenshots -Dette er et samlet screenshotsaet af TuxDMX WebUI genereret den 23. juli 2026. +Dette er et samlet screenshotsaet af TuxDMX WebUI genereret den 25. juli 2026. Kilde: @@ -10,6 +10,7 @@ Kilde: Formaal: - vise sideopbygning +- vise den kompakte navigation med grupperede undermenuer - understoette manualen - give et hurtigt visuelt overblik over systemet @@ -51,6 +52,10 @@ Formaal: ![MixItUp](./screenshots/mixitup.png) +### MIDI + +![MIDI](./screenshots/midi.png) + ### DMX monitor ![DMX monitor](./screenshots/dmx-monitor.png) diff --git a/docs/system-manual/screenshots/backup.png b/docs/system-manual/screenshots/backup.png index 04bd9dc..772a126 100644 Binary files a/docs/system-manual/screenshots/backup.png and b/docs/system-manual/screenshots/backup.png differ diff --git a/docs/system-manual/screenshots/bpm.png b/docs/system-manual/screenshots/bpm.png index 42c69e9..bdaaf71 100644 Binary files a/docs/system-manual/screenshots/bpm.png and b/docs/system-manual/screenshots/bpm.png differ diff --git a/docs/system-manual/screenshots/dashboard.png b/docs/system-manual/screenshots/dashboard.png index 15f8098..e1f765a 100644 Binary files a/docs/system-manual/screenshots/dashboard.png and b/docs/system-manual/screenshots/dashboard.png differ diff --git a/docs/system-manual/screenshots/dmx-monitor.png b/docs/system-manual/screenshots/dmx-monitor.png index 68e66eb..c28f795 100644 Binary files a/docs/system-manual/screenshots/dmx-monitor.png and b/docs/system-manual/screenshots/dmx-monitor.png differ diff --git a/docs/system-manual/screenshots/effects.png b/docs/system-manual/screenshots/effects.png index b7802b2..75e4b96 100644 Binary files a/docs/system-manual/screenshots/effects.png and b/docs/system-manual/screenshots/effects.png differ diff --git a/docs/system-manual/screenshots/fixtures.png b/docs/system-manual/screenshots/fixtures.png index 3ee0a8f..cb7bb91 100644 Binary files a/docs/system-manual/screenshots/fixtures.png and b/docs/system-manual/screenshots/fixtures.png differ diff --git a/docs/system-manual/screenshots/live-desk.png b/docs/system-manual/screenshots/live-desk.png index 8905dd4..1210bdd 100644 Binary files a/docs/system-manual/screenshots/live-desk.png and b/docs/system-manual/screenshots/live-desk.png differ diff --git a/docs/system-manual/screenshots/midi.png b/docs/system-manual/screenshots/midi.png new file mode 100644 index 0000000..bafb8d8 Binary files /dev/null and b/docs/system-manual/screenshots/midi.png differ diff --git a/docs/system-manual/screenshots/mixitup.png b/docs/system-manual/screenshots/mixitup.png index 1a75bae..b2a6e5a 100644 Binary files a/docs/system-manual/screenshots/mixitup.png and b/docs/system-manual/screenshots/mixitup.png differ diff --git a/docs/system-manual/screenshots/patch.png b/docs/system-manual/screenshots/patch.png index b8b7e1a..3050f77 100644 Binary files a/docs/system-manual/screenshots/patch.png and b/docs/system-manual/screenshots/patch.png differ diff --git a/docs/system-manual/screenshots/placement.png b/docs/system-manual/screenshots/placement.png index 70c4701..f6889e7 100644 Binary files a/docs/system-manual/screenshots/placement.png and b/docs/system-manual/screenshots/placement.png differ diff --git a/docs/system-manual/screenshots/scenes.png b/docs/system-manual/screenshots/scenes.png index 6112cdb..fb3e710 100644 Binary files a/docs/system-manual/screenshots/scenes.png and b/docs/system-manual/screenshots/scenes.png differ diff --git a/docs/system-manual/screenshots/settings.png b/docs/system-manual/screenshots/settings.png index fa5f1f3..23f7197 100644 Binary files a/docs/system-manual/screenshots/settings.png and b/docs/system-manual/screenshots/settings.png differ diff --git a/docs/system-manual/screenshots/telemetry.png b/docs/system-manual/screenshots/telemetry.png index a90a2df..ed0c26b 100644 Binary files a/docs/system-manual/screenshots/telemetry.png and b/docs/system-manual/screenshots/telemetry.png differ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..624792a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,37 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["frontend/dist/**", "node_modules/**"], + }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["frontend/src/**/*.{ts,tsx}", "frontend/tests/**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: { + ...globals.browser, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..251ccad --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + TuxDMX + + + +
+ + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f25b858 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,1280 @@ +import { useCallback, useEffect, useState } from "react"; + +import { AppShell } from "./components/AppShell"; +import { + api, + ArtNetNode, + BpmDevice, + DmxDevice, + DmxOutputConfig, + FixtureSummary, + HomeAssistantConfig, + HomeAssistantEntity, + HomeAssistantMapping, + LiveMixerItem, + MidiBridgeStatus, + MidiBridgeToken, + MidiLearnState, + MidiMapping, + PatchItem, + SceneItem, + SystemStatus, +} from "./lib/api"; +import { BackupPage } from "./pages/BackupPage"; +import { BpmPage } from "./pages/BpmPage"; +import { DashboardPage } from "./pages/DashboardPage"; +import { EffectsPage } from "./pages/EffectsPage"; +import { FixturesPage } from "./pages/FixturesPage"; +import { LiveDeskPage } from "./pages/LiveDeskPage"; +import { MixitupPage } from "./pages/MixitupPage"; +import { MidiPage } from "./pages/MidiPage"; +import { MonitorPage } from "./pages/MonitorPage"; +import { PatchPage } from "./pages/PatchPage"; +import { PlacementPage } from "./pages/PlacementPage"; +import { ScenesPage } from "./pages/ScenesPage"; +import { SettingsPage } from "./pages/SettingsPage"; +import { TelemetryPage } from "./pages/TelemetryPage"; + +const navigationSections = [ + { + id: "overview", + label: "Overblik", + icon: "overview" as const, + tone: "accent" as const, + items: [ + { id: "dashboard", label: "Dashboard", icon: "dashboard" as const }, + { id: "live", label: "Live Desk", icon: "live" as const } + ] + }, + { + id: "live-control", + label: "Live-kontrol", + icon: "live-control" as const, + items: [ + { id: "scenes", label: "Scener", icon: "scenes" as const }, + { id: "effects", label: "Effekter", icon: "effects" as const }, + { id: "bpm", label: "BPM", icon: "bpm" as const } + ] + }, + { + id: "lighting-setup", + label: "Lysopsætning", + icon: "lighting-setup" as const, + items: [ + { id: "fixtures", label: "Fixtures", icon: "fixtures" as const }, + { id: "patch", label: "DMX Patch", icon: "patch" as const }, + { id: "placement", label: "Placering", icon: "placement" as const } + ] + }, + { + id: "integrations", + label: "Integrationer", + icon: "integrations" as const, + items: [ + { id: "mixitup", label: "MixItUp", icon: "mixitup" as const }, + { id: "midi", label: "MIDI", icon: "midi" as const } + ] + }, + { + id: "monitoring", + label: "Overvågning", + icon: "monitoring" as const, + items: [ + { id: "monitor", label: "DMX Monitor", icon: "monitor" as const }, + { id: "telemetry", label: "Telemetri", icon: "telemetry" as const } + ] + }, + { + id: "system", + label: "System", + icon: "system" as const, + items: [ + { id: "backup", label: "Backup", icon: "backup" as const }, + { id: "settings", label: "Indstillinger", icon: "settings" as const } + ] + } +]; + +const mockStatus: SystemStatus = { + app: "TuxDMX", + setup_required: false, + engine: { + backend: "simulator", + connected: true, + degraded: false, + last_error: null, + blackout: false, + fps: 30, + master: 255, + frame: Array.from({ length: 512 }, (_, index) => (index % 16) * 16), + source_map: Array.from({ length: 512 }, (_, index) => + index < 12 ? "scene:stream-base" : index < 20 ? "effect:raid-flash" : "idle" + ), + updated_at: "2026-07-12T15:00:05+02:00" + }, + telemetry: { + uptime_seconds: 7242, + cpu_percent: 18, + ram_percent: 41, + temperature_c: 52, + queue_depth: 1, + reconnect_count: 0, + last_error: null, + events: [ + { + created_at: "2026-07-12T15:00:00+02:00", + category: "DMX", + level: "INFO", + message: "Simulatorframe afleveret til backend" + }, + { + created_at: "2026-07-12T15:00:05+02:00", + category: "MIXITUP", + level: "INFO", + message: "Raid-trigger lagt i kø" + } + ] + }, + bpm: { + mode: "audio:alsa:plughw:CARD=SB,DEV=0", + bpm: 128, + confidence: 0.92, + devices: ["alsa:auto", "alsa:plughw:CARD=SB,DEV=0", "alsa:plughw:1,0", "synthetic-click-track"], + device_details: [ + { + id: "alsa:auto", + name: "Auto (SB -> plughw:CARD=SB,DEV=0 -> plughw:1,0 -> default)", + backend: "alsa", + is_default: true + }, + { + id: "alsa:plughw:CARD=SB,DEV=0", + name: "HDA ATI SB / ALC883 Analog (stabil alias)", + backend: "alsa", + is_default: false, + recommended: true + }, + { id: "alsa:plughw:1,0", name: "HDA ATI SB / ALC883 Analog (plughw)", backend: "alsa", is_default: false }, + { id: "synthetic-click-track", name: "Syntetisk click track", backend: "synthetic", is_default: false } + ], + current_device: "alsa:plughw:CARD=SB,DEV=0", + selected_device: "alsa:auto", + recommended_device: "alsa:plughw:CARD=SB,DEV=0", + audio_connected: true, + last_error: null, + last_beat_at: 12.5, + input_level: 0.42, + peak_level: 0.68, + clipping: false, + sample_rate: 44100, + channels: 1, + format: "S16_LE" + } +}; + +const mockFixtures: FixtureSummary[] = [ + { + id: 1, + slug: "eurolite/led-bar-3-hcl-bar", + manufacturer: "Eurolite", + model: "LED Bar-3 HCL Bar", + short_name: "BRR-3", + categories: ["Color Changer"], + schema_version: "custom", + source: { manufacturer_key: "eurolite", fixture_key: "led-bar-3-hcl-bar" }, + warnings: [], + modes: [ + { + key: "10-channel", + channel_count: 10, + channels: [ + { index: 1, key: "Dimmer", display_name: "Dimmer", precedence: "htp", resolution: 8 }, + { index: 2, key: "Strobe", display_name: "Strobe", precedence: "ltp", resolution: 8 }, + { index: 3, key: "Red", display_name: "Red", precedence: "ltp", resolution: 8 }, + { index: 4, key: "Green", display_name: "Green", precedence: "ltp", resolution: 8 }, + { index: 5, key: "Blue", display_name: "Blue", precedence: "ltp", resolution: 8 }, + { index: 6, key: "White", display_name: "White", precedence: "ltp", resolution: 8 }, + { index: 7, key: "Amber", display_name: "Amber", precedence: "ltp", resolution: 8 }, + { index: 8, key: "UV", display_name: "UV", precedence: "ltp", resolution: 8 }, + { index: 9, key: "Program", display_name: "Program", precedence: "ltp", resolution: 8 }, + { index: 10, key: "Speed", display_name: "Speed", precedence: "ltp", resolution: 8 } + ] + } + ] + } +]; + +const mockPatches: PatchItem[] = [ + { + id: 1, + universe: 1, + name: "Bar venstre", + definition_id: 1, + fixture_slug: "eurolite/led-bar-3-hcl-bar", + manufacturer: "Eurolite", + model: "LED Bar-3 HCL Bar", + mode_key: "10-channel", + start_address: 1, + end_address: 10, + channel_count: 10, + enabled: true, + group_names: ["front", "wash", "synk-a"], + position: { x: 18, y: 32, z: 12, rotation: 8 }, + channels: mockFixtures[0].modes[0].channels + }, + { + id: 2, + universe: 1, + name: "Bar højre", + definition_id: 1, + fixture_slug: "eurolite/led-bar-3-hcl-bar", + manufacturer: "Eurolite", + model: "LED Bar-3 HCL Bar", + mode_key: "10-channel", + start_address: 11, + end_address: 20, + channel_count: 10, + enabled: true, + group_names: ["front", "wash", "synk-a"], + position: { x: 78, y: 34, z: 12, rotation: 352 }, + channels: mockFixtures[0].modes[0].channels + } +]; + +const mockScenes: SceneItem[] = [ + { + id: 1, + name: "Stream base", + slug: "stream-base", + color: "#00e5ff", + icon: null, + priority: 10, + fade_in_ms: 500, + fade_out_ms: 500, + hold_ms: 0, + master_limit: 255, + values: [], + targets: [ + { + target_type: "group", + group_name: "synk-a", + values: [ + { attribute: "Dimmer", value: 200, precedence: "htp" }, + { attribute: "Blue", value: 180, precedence: "ltp" } + ] + } + ], + tags: ["stream"], + is_active: true + } +]; + +const mockEffects = [ + { name: "Raid flash", slug: "raid-flash", effect_type: "beat-flash" }, + { name: "Rainbow", slug: "rainbow", effect_type: "color-chase" } +]; + +const mockBackups = [ + { id: "20260712150000", label: "backup-20260712150000", created_at: "2026-07-12T15:00:00+02:00" } +]; + +const mockMixerItems: LiveMixerItem[] = mockPatches.map((patch) => ({ + id: `patch:${patch.id}`, + source_type: "patch", + source_id: patch.id, + patch_id: patch.id, + name: patch.name, + manufacturer: patch.manufacturer, + model: patch.model, + mode_key: patch.mode_key, + universe: patch.universe, + start_address: patch.start_address, + end_address: patch.end_address, + channel_count: patch.channel_count, + entity_id: null, + status: "active", + last_sent_summary: null, + channels: patch.channels.slice(0, 6).map((channel) => ({ + index: channel.index, + absolute_channel: patch.start_address + channel.index - 1, + key: channel.key, + display_name: channel.display_name, + precedence: channel.precedence, + resolution: channel.resolution, + value: channel.index === 1 ? 220 : 0 + })) +})); + +mockMixerItems.push({ + id: "home_assistant:1", + source_type: "home_assistant", + source_id: 1, + patch_id: null, + name: "Loft bunker", + manufacturer: "Home Assistant", + model: "switch", + mode_key: "HA switch", + universe: 10, + start_address: 1, + end_address: 1, + channel_count: 1, + entity_id: "light.loft_bunker", + status: "active", + last_sent_summary: "light.turn_on light.loft_bunker", + channels: [ + { + index: 1, + absolute_channel: 1, + key: "Switch", + display_name: "State", + precedence: "ltp", + resolution: 8, + value: 0 + } + ] +}); + +const mockFixturePreview = { + manufacturer: "Eurolite", + model: "LED Bar-3 HCL Bar", + categories: ["Color Changer"], + schema_version: "https://example.test/ofl-schema-v1", + modes: mockFixtures[0].modes +}; + +const mockArtNetNodes: ArtNetNode[] = [ + { + ip: "192.168.2.60", + short_name: "WLED Matrix", + long_name: "WLED Matrix Loft", + label: "WLED Matrix Loft (192.168.2.60)", + net: 0, + sub_switch: 0, + port_count: 1, + raw_port_address: 0 + }, + { + ip: "192.168.2.61", + short_name: "Paravega Node", + long_name: "Paravega Front Rig", + label: "Paravega Front Rig (192.168.2.61)", + net: 0, + sub_switch: 0, + port_count: 4, + raw_port_address: 1 + } +]; + +const mockDmxConfig: DmxOutputConfig = { + backend: "artnet", + universe: 1, + output_port: "WLED Matrix Loft", + target_host: "192.168.2.60", + artnet_nodes: mockArtNetNodes +}; + +const mockDmxDevices: DmxDevice[] = [ + { + name: "Art-Net output 192.168.2.60", + backend: "artnet", + connected: true, + output_port: "WLED Matrix Loft", + universe: 1 + } +]; + +const mockHomeAssistantConfig: HomeAssistantConfig = { + enabled: true, + base_url: "http://homeassistant.local:8123", + default_universe: 10, + has_token: true, + token_mask: "********", + mapping_count: 1, + dispatch_count: 12, + error_count: 0, + last_error: null, + last_successful_call_at: "2026-07-23T12:40:00+02:00", + last_connection_success_at: "2026-07-23T12:39:55+02:00", + last_connection_error: null, + ha_version: "2026.7.0", + auth_ok: true, + reachable: true +}; + +const mockHomeAssistantEntities: HomeAssistantEntity[] = [ + { + entity_id: "light.bar_rgb", + domain: "light", + friendly_name: "Bar RGB", + state: "on" + } +]; + +const mockHomeAssistantMappings: HomeAssistantMapping[] = [ + { + id: 1, + name: "Bar RGB via HA", + universe: 10, + start_address: 1, + fixture_type: "rgb", + entity_id: "light.bar_rgb", + rate_limit_hz: 5, + deadband: 2, + fade_ms: 150, + invert_channel: false, + min_value: 0, + max_value: 255, + enabled: true, + master_dimmer: true, + channel_span: 4, + status: "active", + last_dmx_values: [255, 255, 80, 80], + last_sent_summary: "light.turn_on light.bar_rgb rgb=[255,80,80] brightness=255", + last_service: "light.turn_on", + last_success_at: "2026-07-23T12:40:00+02:00", + last_error: null, + last_error_at: null, + in_flight: false + } +]; + +const mockMidiBridges: MidiBridgeStatus[] = [ + { + bridge_id: "bar-laptop", + device_name: "USB MIDI Controller", + ip_address: "192.168.2.44", + protocol_version: 1, + online: true, + last_heartbeat_at: "2026-07-24T20:30:10+02:00", + last_event_at: "2026-07-24T20:30:12+02:00", + last_error: null, + last_event: { type: "note_on", channel: 0, number: 36, value: 127 }, + updated_at: "2026-07-24T20:30:12+02:00", + created_at: "2026-07-24T20:20:00+02:00" + } +]; + +const mockMidiMappings: MidiMapping[] = [ + { + id: 1, + name: "Scene A", + enabled: true, + bridge_id: "bar-laptop", + device_name: "USB*", + message_type: "note_on", + channel: 0, + number: 36, + action: "activate_scene", + target_type: "scene", + target_id: "stream-base", + mode: "trigger", + minimum_value: 1, + maximum_value: 127, + created_at: "2026-07-24T20:20:00+02:00", + updated_at: "2026-07-24T20:20:00+02:00", + active: true + } +]; + +const mockMidiTokens: MidiBridgeToken[] = [ + { + id: 1, + label: "Bar laptop", + bridge_id: "bar-laptop", + scopes: ["midi:connect", "midi:events", "midi:heartbeat"], + created_at: "2026-07-24T20:10:00+02:00", + revoked_at: null, + last_used_at: "2026-07-24T20:30:12+02:00", + token_preview: "********9f2a" + } +]; + +const mockMidiLearnState: MidiLearnState = { + active: false, + allow_passthrough: false, + expires_at: null, + captured_event: { + bridge_id: "bar-laptop", + device: "USB MIDI Controller", + timestamp: "2026-07-24T20:28:00+02:00", + message: { type: "control_change", channel: 0, number: 14, value: 64 } + } +}; + +export default function App() { + const searchParams = new URLSearchParams(window.location.search); + const isMock = searchParams.get("mock") === "1"; + const initialPage = searchParams.get("page") ?? "dashboard"; + + const [currentPage, setCurrentPage] = useState(initialPage); + const [status, setStatus] = useState(isMock ? mockStatus : null); + const [fixtures, setFixtures] = useState(isMock ? mockFixtures : []); + const [fixtureResults, setFixtureResults] = useState>( + isMock ? [{ fixture_key: "eurolite/led-bar-3-hcl-bar", cached: true }] : [] + ); + const [scenes, setScenes] = useState(isMock ? mockScenes : []); + const [effects, setEffects] = useState>>(isMock ? mockEffects : []); + const [backups, setBackups] = useState>>(isMock ? mockBackups : []); + const [patches, setPatches] = useState(isMock ? mockPatches : []); + const [liveMixer, setLiveMixer] = useState(isMock ? mockMixerItems : []); + const [bpmDevices, setBpmDevices] = useState( + isMock ? mockStatus.bpm.device_details ?? [] : [] + ); + const [dmxConfig, setDmxConfig] = useState(isMock ? mockDmxConfig : null); + const [dmxDevices, setDmxDevices] = useState(isMock ? mockDmxDevices : []); + const [haConfig, setHaConfig] = useState(isMock ? mockHomeAssistantConfig : null); + const [haEntities, setHaEntities] = useState(isMock ? mockHomeAssistantEntities : []); + const [haMappings, setHaMappings] = useState(isMock ? mockHomeAssistantMappings : []); + const [midiBridges, setMidiBridges] = useState(isMock ? mockMidiBridges : []); + const [midiMappings, setMidiMappings] = useState(isMock ? mockMidiMappings : []); + const [midiTokens, setMidiTokens] = useState(isMock ? mockMidiTokens : []); + const [midiLearnState, setMidiLearnState] = useState(isMock ? mockMidiLearnState : null); + const [preferredPatchFixtureId, setPreferredPatchFixtureId] = useState(null); + + const refresh = useCallback(async () => { + if (isMock) { + return; + } + const [ + statusData, + fixtureData, + sceneData, + effectData, + backupData, + patchData, + liveMixerData, + bpmDeviceData, + dmxConfigData, + dmxDeviceData, + haConfigData, + haMappingData, + midiBridgeData, + midiMappingData, + midiTokenData, + midiLearnData + ] = await Promise.all([ + api.getStatus(), + api.getFixtures(), + api.listScenes(), + api.listEffects(), + api.listBackups(), + api.listPatches(), + api.getLiveMixer(), + api.listBpmDevices(), + api.getDmxConfig(), + api.listDmxDevices(), + api.getHomeAssistantConfig(), + api.listHomeAssistantMappings(), + api.listMidiBridges(), + api.listMidiMappings(), + api.listMidiTokens(), + api.getMidiLearnState() + ]); + setStatus(statusData); + setFixtures(fixtureData.items); + setScenes(sceneData.items); + setEffects(effectData.items); + setBackups(backupData.items); + setPatches(patchData.items); + setLiveMixer(liveMixerData.items); + setBpmDevices(bpmDeviceData.devices); + setDmxConfig(dmxConfigData); + setDmxDevices(dmxDeviceData.devices); + setHaConfig(haConfigData); + setHaMappings(haMappingData.items); + setMidiBridges(midiBridgeData.items); + setMidiMappings(midiMappingData.items); + setMidiTokens(midiTokenData.items); + setMidiLearnState(midiLearnData); + }, [isMock]); + + useEffect(() => { + if (isMock) { + return; + } + void refresh(); + const refreshTimer = window.setInterval(() => { + void refresh(); + }, 5000); + + let socket: WebSocket | null = null; + let reconnectTimer: number | null = null; + let stopped = false; + + const connectSocket = () => { + if (stopped) { + return; + } + socket = new WebSocket(`${window.location.origin.replace(/^http/, "ws")}/ws/live`); + socket.onopen = () => { + void refresh(); + }; + socket.onmessage = (event) => { + const payload = JSON.parse(event.data) as { + engine: SystemStatus["engine"]; + telemetry: SystemStatus["telemetry"]; + bpm: SystemStatus["bpm"]; + }; + setStatus((previous) => + previous + ? { ...previous, engine: payload.engine, telemetry: payload.telemetry, bpm: payload.bpm } + : { + app: "TuxDMX", + setup_required: false, + engine: payload.engine, + telemetry: payload.telemetry, + bpm: payload.bpm + } + ); + }; + socket.onerror = () => { + socket?.close(); + }; + socket.onclose = () => { + if (stopped) { + return; + } + void refresh(); + reconnectTimer = window.setTimeout(() => { + connectSocket(); + }, 1500); + }; + }; + + connectSocket(); + + return () => { + stopped = true; + window.clearInterval(refreshTimer); + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer); + } + socket?.close(); + }; + }, [isMock, refresh]); + + const page = (() => { + switch (currentPage) { + case "fixtures": + return ( + { + if (isMock) { + setFixtureResults([{ fixture_key: "eurolite/led-bar-3-hcl-bar", cached: true }]); + return; + } + const data = await api.searchFixtures(query); + setFixtureResults(data.results); + }} + onPreview={(manufacturerKey, fixtureKey) => + isMock ? Promise.resolve(mockFixturePreview) : api.previewFixture(manufacturerKey, fixtureKey) + } + onImport={async (manufacturerKey, fixtureKey) => { + if (isMock) { + void manufacturerKey; + void fixtureKey; + const fixture = mockFixtures[0]; + setPreferredPatchFixtureId(fixture.id); + setCurrentPage("patch"); + return fixture; + } + const fixture = await api.importFixture(manufacturerKey, fixtureKey); + await refresh(); + setPreferredPatchFixtureId(fixture.id); + setCurrentPage("patch"); + return fixture; + }} + onImportFile={async (payload) => { + if (isMock) { + const fixture = mockFixtures[0]; + void payload; + setPreferredPatchFixtureId(fixture.id); + setCurrentPage("patch"); + return fixture; + } + const fixture = await api.importFixtureFile(payload); + await refresh(); + setPreferredPatchFixtureId(fixture.id); + setCurrentPage("patch"); + return fixture; + }} + onConfigureFixture={(fixtureId) => { + setPreferredPatchFixtureId(fixtureId); + setCurrentPage("patch"); + }} + /> + ); + case "live": + return ( + { + await api.blackout(); + await refresh(); + }} + onReleaseBlackout={async () => { + await api.releaseBlackout(); + await refresh(); + }} + onSetChannel={async (item, channelIndex, value) => { + if (isMock) { + return; + } + const current = liveMixer.find((entry) => entry.id === item.id); + const nextValues = Object.fromEntries( + (current?.channels ?? []).map((channel) => [ + channel.index, + channel.index === channelIndex ? value : channel.value + ]) + ) as Record; + const response = + item.source_type === "home_assistant" + ? await api.setLiveMixerHomeAssistantValues(item.source_id, nextValues) + : await api.setLiveMixerPatchValues(item.source_id, nextValues); + setLiveMixer(response.items); + }} + onClearFixture={async (item) => { + if (isMock) { + return; + } + const response = + item.source_type === "home_assistant" + ? await api.clearLiveMixerHomeAssistantValues(item.source_id) + : await api.clearLiveMixerPatchValues(item.source_id); + setLiveMixer(response.items); + }} + /> + ); + case "patch": + return ( + ( + isMock + ? Promise.resolve({ + valid: true, + range: "1-10", + end_address: 10, + channel_count: 10, + occupied_channels: [11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + conflicts: [] + }) + : api.validatePatch(payload) + )} + onSave={async (payload, patchId) => { + if (isMock) { + return; + } + if (patchId === null) { + await api.createPatch(payload); + } else { + await api.updatePatch(patchId, payload); + } + await refresh(); + }} + onDelete={async (patchId) => { + if (isMock) { + return; + } + await api.deletePatch(patchId); + await refresh(); + }} + /> + ); + case "placement": + return ( + { + if (isMock) { + return; + } + const patch = patches.find((item) => item.id === patchId); + if (!patch) { + return; + } + await api.updatePatch(patchId, { + universe: patch.universe, + name: patch.name, + definition_id: patch.definition_id, + mode_key: patch.mode_key, + start_address: patch.start_address, + enabled: patch.enabled, + group_names: patch.group_names, + position + }); + await refresh(); + }} + /> + ); + case "scenes": + return ( + { + await api.createScene(payload); + await refresh(); + }} + onActivate={async (slug) => { + await api.activateScene(slug); + await refresh(); + }} + onRelease={async (slug) => { + await api.releaseScene(slug); + await refresh(); + }} + onDelete={async (sceneId) => { + await api.deleteScene(sceneId); + await refresh(); + }} + /> + ); + case "effects": + return ( + { + await api.createEffect(payload); + await refresh(); + }} + onTrigger={async (slug) => { + await api.triggerEffect(slug); + await refresh(); + }} + onStop={async (slug) => { + await api.stopEffect(slug); + await refresh(); + }} + /> + ); + case "bpm": + return ( + { + await api.tapBpm(); + await refresh(); + }} + onSetManual={async (bpm) => { + await api.setManualBpm(bpm); + await refresh(); + }} + onStartAudio={async (device) => { + await api.startAudioBpm(device); + await refresh(); + }} + onStopAudio={async () => { + await api.stopAudioBpm(); + await refresh(); + }} + onCreateEffect={async (payload) => { + await api.createEffect(payload); + await refresh(); + }} + onTriggerEffect={async (slug) => { + await api.triggerEffect(slug); + await refresh(); + }} + onStopEffect={async (slug) => { + await api.stopEffect(slug); + await refresh(); + }} + /> + ); + case "mixitup": + return ( + { + await api.triggerMixitup("raid", { + eventType: "raid", + displayName: "TestRaid", + amount: 42, + eventId: "local-test" + }); + await refresh(); + }} + /> + ); + case "midi": + return ( + { + if (isMock) { + const next: MidiMapping = { + id: mappingId ?? Date.now(), + name: String(payload.name), + enabled: Boolean(payload.enabled), + bridge_id: (payload.bridge_id as string | null) ?? null, + device_name: (payload.device_name as string | null) ?? null, + message_type: payload.message_type as MidiMapping["message_type"], + channel: (payload.channel as number | null) ?? null, + number: Number(payload.number), + action: payload.action as MidiMapping["action"], + target_type: payload.target_type as MidiMapping["target_type"], + target_id: (payload.target_id as string | null) ?? null, + mode: payload.mode as MidiMapping["mode"], + minimum_value: Number(payload.minimum_value), + maximum_value: Number(payload.maximum_value), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + active: false + }; + setMidiMappings((previous) => + mappingId === null ? [...previous, next] : previous.map((item) => (item.id === mappingId ? next : item)) + ); + return "Mock: MIDI-mapping gemt."; + } + if (mappingId === null) { + await api.createMidiMapping(payload); + } else { + await api.updateMidiMapping(mappingId, payload); + } + await refresh(); + return "MIDI-mapping gemt."; + }} + onDeleteMapping={async (mappingId) => { + if (isMock) { + setMidiMappings((previous) => previous.filter((mapping) => mapping.id !== mappingId)); + return; + } + await api.deleteMidiMapping(mappingId); + await refresh(); + }} + onTestMapping={async (mappingId) => { + if (isMock) { + return "Mock: MIDI-test sendt."; + } + await api.testMidiMapping(mappingId); + await refresh(); + return "MIDI-test sendt."; + }} + onCreateToken={async (payload) => { + if (isMock) { + const created: MidiBridgeToken = { + id: Date.now(), + label: payload.label, + bridge_id: payload.bridge_id, + scopes: payload.scopes, + created_at: new Date().toISOString(), + revoked_at: null, + last_used_at: null, + token_preview: "********mock", + token: "tuxmidi_mock_token" + }; + setMidiTokens((previous) => [...previous, created]); + return created; + } + const created = await api.createMidiToken(payload); + await refresh(); + return created; + }} + onRevokeToken={async (tokenId) => { + if (isMock) { + setMidiTokens((previous) => + previous.map((token) => + token.id === tokenId ? { ...token, revoked_at: new Date().toISOString() } : token + ) + ); + return; + } + await api.revokeMidiToken(tokenId); + await refresh(); + }} + onStartLearn={async (timeoutSeconds, allowPassthrough) => { + if (isMock) { + setMidiLearnState({ + active: true, + allow_passthrough: allowPassthrough, + expires_at: new Date(Date.now() + timeoutSeconds * 1000).toISOString(), + captured_event: null + }); + return "Mock: MIDI Learn armeret."; + } + await api.startMidiLearn(timeoutSeconds, allowPassthrough); + await refresh(); + return "MIDI Learn armeret."; + }} + onCancelLearn={async () => { + if (isMock) { + setMidiLearnState((previous) => (previous ? { ...previous, active: false } : previous)); + return "Mock: MIDI Learn annulleret."; + } + await api.cancelMidiLearn(); + await refresh(); + return "MIDI Learn annulleret."; + }} + /> + ); + case "monitor": + return ( + 0)} + sources={status?.engine.source_map ?? Array.from({ length: 512 }, () => "idle")} + events={status?.telemetry.events ?? []} + updatedAt={status?.engine.updated_at ?? null} + /> + ); + case "telemetry": + return ; + case "backup": + return ( + { + await api.createBackup(); + await refresh(); + }} + onRestore={async (backupId) => { + await api.restoreBackup(backupId); + await refresh(); + }} + /> + ); + case "settings": + return ( + { + if (isMock) { + return `Mock: Standardinput gemt som ${device}.`; + } + const result = await api.saveBpmConfig(device); + await refresh(); + return `Standardinput gemt som ${String(result.preferred_device).replace(/^alsa:/, "")}.`; + }} + onRefreshAudioDevices={async () => { + await refresh(); + }} + onSaveHomeAssistantConfig={async (payload) => { + if (isMock) { + setHaConfig({ + ...mockHomeAssistantConfig, + enabled: payload.enabled, + base_url: payload.base_url, + default_universe: payload.default_universe, + has_token: Boolean(payload.token) || mockHomeAssistantConfig.has_token, + token_mask: Boolean(payload.token) || mockHomeAssistantConfig.has_token ? "********" : "", + mapping_count: haMappings.length + }); + return "Mock: Home Assistant-konfiguration gemt."; + } + await api.saveHomeAssistantConfig(payload); + await refresh(); + return "Home Assistant-konfiguration gemt."; + }} + onTestHomeAssistantConnection={async () => { + if (isMock) { + setHaConfig((previous) => + previous + ? { + ...previous, + reachable: true, + auth_ok: true, + ha_version: "2026.7.0", + last_connection_success_at: "2026-07-23T12:41:00+02:00", + last_connection_error: null + } + : previous + ); + return "Forbindelse OK · Home Assistant 2026.7.0 · login godkendt."; + } + const result = await api.testHomeAssistantConnection(); + await refresh(); + return result.reachable + ? `Forbindelse OK · Home Assistant ${result.ha_version ?? "ukendt"} · ${result.auth_ok ? "login godkendt" : "loginfejl"}.` + : `Forbindelsesfejl: ${result.last_error ?? "ukendt fejl"}`; + }} + onRefreshHomeAssistantEntities={async () => { + if (isMock) { + setHaEntities(mockHomeAssistantEntities); + return "Mock: Home Assistant-enheder opdateret."; + } + const result = await api.listHomeAssistantEntities(); + setHaEntities(result.items); + return `Hentede ${result.items.length} Home Assistant-enheder.`; + }} + onSaveHomeAssistantMapping={async (payload, mappingId) => { + if (isMock) { + if (mappingId === null) { + const created: HomeAssistantMapping = { + id: Date.now(), + ...payload, + channel_span: + payload.fixture_type === "rgb" + ? (payload.master_dimmer ? 4 : 3) + : payload.fixture_type === "rgbw" + ? (payload.master_dimmer ? 5 : 4) + : payload.fixture_type === "cct" + ? (payload.master_dimmer ? 3 : 2) + : 1, + status: payload.enabled ? "active" : "disabled", + last_dmx_values: [], + last_sent_summary: null, + last_service: null, + last_success_at: null, + last_error: null, + last_error_at: null, + in_flight: false + }; + setHaMappings((previous) => [...previous, created]); + } else { + setHaMappings((previous) => + previous.map((item) => (item.id === mappingId ? { ...item, ...payload } : item)) + ); + } + return "Mock: HA-mapping gemt."; + } + if (mappingId === null) { + await api.createHomeAssistantMapping(payload); + } else { + await api.updateHomeAssistantMapping(mappingId, payload); + } + await refresh(); + return "HA-mapping gemt."; + }} + onDeleteHomeAssistantMapping={async (mappingId) => { + if (isMock) { + setHaMappings((previous) => previous.filter((item) => item.id !== mappingId)); + return; + } + await api.deleteHomeAssistantMapping(mappingId); + await refresh(); + }} + onTestHomeAssistantMapping={async (mappingId) => { + if (isMock) { + return "Mock: Testsignal sendt til Home Assistant."; + } + await api.testHomeAssistantMapping(mappingId); + return "Testsignal sendt til Home Assistant."; + }} + onSaveDmxConfig={async (payload) => { + if (isMock) { + const nextConfig: DmxOutputConfig = { + ...mockDmxConfig, + ...payload, + artnet_nodes: dmxConfig?.artnet_nodes ?? mockArtNetNodes + }; + setDmxConfig(nextConfig); + setDmxDevices([ + { + name: + payload.backend === "simulator" + ? "Simulator universe 1" + : payload.backend === "ola" + ? "OLA DMX output" + : `Art-Net output ${payload.target_host || "255.255.255.255"}`, + backend: payload.backend, + connected: true, + output_port: + payload.backend === "simulator" + ? "simulator" + : payload.output_port || payload.target_host || null, + universe: payload.universe + } + ]); + setStatus((previous) => + previous + ? { + ...previous, + engine: { + ...previous.engine, + backend: payload.backend, + connected: true + } + } + : previous + ); + return `Mock: DMX-output gemt som ${payload.backend.toUpperCase()} på universe ${payload.universe}.`; + } + const result = await api.saveDmxConfig(payload); + await refresh(); + return `DMX-output gemt som ${String(result.backend).toUpperCase()} på universe ${result.universe}.`; + }} + onDiscoverArtNet={async () => { + if (isMock) { + setDmxConfig((previous) => + previous + ? { ...previous, artnet_nodes: mockArtNetNodes } + : { ...mockDmxConfig, artnet_nodes: mockArtNetNodes } + ); + return `Mock: Fandt ${mockArtNetNodes.length} Art-Net enheder.`; + } + const result = await api.discoverArtNet(1); + await refresh(); + return `Fandt ${result.count} Art-Net enheder.`; + }} + onRestartService={async () => { + if (isMock) { + return "Mock: TuxDMX service er planlagt til genstart."; + } + const result = await api.restartService(); + return result.detail; + }} + onRebootHost={async () => { + if (isMock) { + return "Mock: Hostmaskinen er planlagt til genstart."; + } + const result = await api.rebootHost(); + return result.detail; + }} + /> + ); + default: + return ( + { + await api.blackout(); + await refresh(); + }} + onReleaseBlackout={async () => { + await api.releaseBlackout(); + await refresh(); + }} + onRestartService={async () => { + if (isMock) { + return "Mock: TuxDMX service er planlagt til genstart."; + } + const result = await api.restartService(); + return result.detail; + }} + onRebootHost={async () => { + if (isMock) { + return "Mock: Hostmaskinen er planlagt til genstart."; + } + const result = await api.rebootHost(); + return result.detail; + }} + /> + ); + } + })(); + + return ( + + {page} + + ); +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..7a58f28 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,269 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; + +type AppShellProps = { + currentPage: string; + sections: Array<{ + id: string; + label: string; + icon: SectionIconName; + tone?: "accent" | "muted"; + items: Array<{ id: string; label: string; icon: IconName }>; + }>; + onNavigate: (page: string) => void; + children: ReactNode; +}; + +type IconName = + | "dashboard" + | "live" + | "scenes" + | "effects" + | "bpm" + | "fixtures" + | "patch" + | "placement" + | "mixitup" + | "midi" + | "monitor" + | "telemetry" + | "backup" + | "settings"; + +type SectionIconName = "overview" | "live-control" | "lighting-setup" | "integrations" | "monitoring" | "system"; + +function NavIcon({ name }: { name: IconName }) { + const commonProps = { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.8, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, + }; + + switch (name) { + case "dashboard": + return ( + + + + ); + case "live": + return ( + + + + + + + ); + case "scenes": + return ( + + + + + ); + case "effects": + return ( + + + + ); + case "bpm": + return ( + + + + ); + case "fixtures": + return ( + + + + ); + case "patch": + return ( + + + + + ); + case "placement": + return ( + + + + + ); + case "mixitup": + return ( + + + + + + ); + case "midi": + return ( + + + + + ); + case "monitor": + return ( + + + + + ); + case "telemetry": + return ( + + + + ); + case "backup": + return ( + + + + + ); + case "settings": + return ( + + + + + ); + } +} + +function SectionIcon({ name }: { name: SectionIconName }) { + const commonProps = { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.8, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, + }; + + switch (name) { + case "overview": + return ( + + + + ); + case "live-control": + return ( + + + + + + + ); + case "lighting-setup": + return ( + + + + ); + case "integrations": + return ( + + + + + ); + case "monitoring": + return ( + + + + ); + case "system": + return ( + + + + + ); + } +} + +export function AppShell({ currentPage, sections, onNavigate, children }: AppShellProps) { + const activeSectionId = useMemo( + () => sections.find((section) => section.items.some((item) => item.id === currentPage))?.id ?? sections[0]?.id ?? "", + [currentPage, sections] + ); + const [openSectionId, setOpenSectionId] = useState(activeSectionId); + + useEffect(() => { + setOpenSectionId(activeSectionId); + }, [activeSectionId]); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/frontend/src/components/ChannelGrid.tsx b/frontend/src/components/ChannelGrid.tsx new file mode 100644 index 0000000..e92fc3d --- /dev/null +++ b/frontend/src/components/ChannelGrid.tsx @@ -0,0 +1,45 @@ +type ChannelGridProps = { + values: number[]; + sources: string[]; +}; + +export function ChannelGrid({ values, sources }: ChannelGridProps) { + const activeChannels = values + .map((value, index) => ({ + channel: index + 1, + value, + source: sources[index] ?? "idle" + })) + .filter((entry) => entry.value > 0 || entry.source !== "idle"); + + if (activeChannels.length === 0) { + return ( +
+ Ingen aktive DMX-kanaler +
Monitoren viser kun kanaler med værdi over 0 eller aktiv kilde.
+
+ ); + } + + return ( +
+ {activeChannels.map((entry) => ( +
+ {entry.channel} + {entry.value} + {entry.source.replace(/^.*:/, "")} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/StatusPill.tsx b/frontend/src/components/StatusPill.tsx new file mode 100644 index 0000000..e223361 --- /dev/null +++ b/frontend/src/components/StatusPill.tsx @@ -0,0 +1,9 @@ +type StatusPillProps = { + label: string; + tone: "success" | "warning" | "danger" | "info"; +}; + +export function StatusPill({ label, tone }: StatusPillProps) { + return {label}; +} + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..f8c4021 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,604 @@ +export type SystemStatus = { + app: string; + setup_required: boolean; + engine: { + backend: string; + connected: boolean; + degraded: boolean; + last_error: string | null; + blackout: boolean; + fps: number; + master: number; + frame: number[]; + source_map: string[]; + updated_at?: string | null; + }; + telemetry: { + uptime_seconds: number; + cpu_percent: number; + ram_percent: number; + temperature_c: number | null; + queue_depth: number; + reconnect_count: number; + last_error: string | null; + events: Array<{ created_at: string; category: string; level: string; message: string }>; + }; + bpm: BpmStatus; +}; + +export type DmxOutputConfig = { + backend: "simulator" | "ola" | "artnet"; + universe: number; + output_port: string; + target_host: string; + artnet_nodes: ArtNetNode[]; +}; + +export type DmxDevice = { + name: string; + backend: string; + connected: boolean; + output_port: string | null; + universe: number; +}; + +export type HomeAssistantConfig = { + enabled: boolean; + base_url: string; + default_universe: number; + has_token: boolean; + token_mask: string; + mapping_count: number; + dispatch_count: number; + error_count: number; + last_error: string | null; + last_successful_call_at: string | null; + last_connection_success_at: string | null; + last_connection_error: string | null; + ha_version: string | null; + auth_ok: boolean; + reachable: boolean; +}; + +export type HomeAssistantConnectionResult = { + reachable: boolean; + auth_ok: boolean; + ha_version: string | null; + last_error: string | null; + last_successful_call_at: string | null; + last_connection_success_at: string | null; +}; + +export type HomeAssistantEntity = { + entity_id: string; + domain: string; + friendly_name: string; + state: string; +}; + +export type HomeAssistantMapping = { + id: number; + name: string; + universe: number; + start_address: number; + fixture_type: "dimmer" | "rgb" | "rgbw" | "cct" | "switch" | "scene" | "automation"; + entity_id: string; + rate_limit_hz: number; + deadband: number; + fade_ms: number; + invert_channel: boolean; + min_value: number; + max_value: number; + enabled: boolean; + master_dimmer: boolean; + channel_span: number; + status: string; + last_dmx_values: number[]; + last_sent_summary: string | null; + last_service: string | null; + last_success_at: string | null; + last_error: string | null; + last_error_at: string | null; + in_flight: boolean; +}; + +export type MidiBridgeStatus = { + bridge_id: string; + device_name: string; + ip_address: string | null; + protocol_version: number; + online: boolean; + last_heartbeat_at: string | null; + last_event_at: string | null; + last_error: string | null; + last_event: { + type: string; + channel: number; + number: number; + value: number; + } | null; + updated_at: string; + created_at: string; +}; + +export type MidiMapping = { + id: number; + name: string; + enabled: boolean; + bridge_id: string | null; + device_name: string | null; + message_type: "note_on" | "note_off" | "control_change" | "program_change"; + channel: number | null; + number: number; + action: + | "activate_scene" + | "deactivate_scene" + | "toggle_scene" + | "flash_scene" + | "blackout_on" + | "blackout_off" + | "blackout_toggle" + | "set_master_dimmer" + | "set_scene_intensity" + | "start_effect" + | "stop_effect"; + target_type: "scene" | "effect" | "system" | "global"; + target_id: string | null; + mode: "trigger" | "toggle" | "hold" | "flash" | "continuous"; + minimum_value: number; + maximum_value: number; + created_at: string; + updated_at: string; + active: boolean; +}; + +export type MidiBridgeToken = { + id: number; + label: string; + bridge_id: string | null; + scopes: string[]; + created_at: string; + revoked_at: string | null; + last_used_at: string | null; + token_preview: string; + token?: string; +}; + +export type MidiLearnState = { + active: boolean; + allow_passthrough: boolean; + expires_at: string | null; + captured_event: { + bridge_id: string; + device: string; + timestamp: string; + message: { + type: string; + channel: number; + number: number; + value: number; + }; + } | null; +}; + +export type ArtNetNode = { + ip: string; + short_name: string; + long_name: string; + label: string; + net: number; + sub_switch: number; + port_count: number; + raw_port_address: number; +}; + +export type BpmDevice = { + id: string; + name: string; + backend: string; + is_default: boolean; + recommended?: boolean; +}; + +export type BpmStatus = { + mode: string; + bpm: number; + confidence: number; + devices: string[]; + device_details?: BpmDevice[]; + current_device: string; + selected_device: string; + recommended_device: string; + audio_connected: boolean; + last_error: string | null; + last_beat_at?: number | null; + input_level: number; + peak_level: number; + clipping: boolean; + sample_rate: number; + channels: number; + format: string; +}; + +export type BpmConfig = { + preferred_device: string; + recommended_device: string; + sample_rate: number; + channels: number; + format: string; +}; + +export type FixturePreview = { + manufacturer: string; + model: string; + categories: string[]; + schema_version: string; + modes: Array<{ + key: string; + channel_count: number; + channels: Array<{ + index: number; + key: string; + display_name: string; + precedence: string; + resolution: number; + }>; + }>; +}; + +export type FixtureSummary = { + id: number; + slug: string; + manufacturer: string; + model: string; + short_name: string | null; + categories: string[]; + schema_version: string; + source: { + manufacturer_key?: string; + fixture_key?: string; + }; + warnings: string[]; + modes: Array<{ + key: string; + channel_count: number; + channels: Array<{ + index: number; + key: string; + display_name: string; + precedence: string; + resolution: number; + capabilities?: unknown[]; + }>; + }>; +}; + +export type PatchItem = { + id: number; + universe: number; + name: string; + definition_id: number; + fixture_slug: string | null; + manufacturer: string | null; + model: string | null; + mode_key: string; + start_address: number; + end_address: number; + channel_count: number; + enabled: boolean; + group_names: string[]; + position: { + x: number; + y: number; + z: number; + rotation: number; + }; + channels: Array<{ + index: number; + key: string; + display_name: string; + precedence: string; + resolution: number; + }>; +}; + +export type SceneItem = { + id: number; + name: string; + slug: string; + color: string; + icon: string | null; + priority: number; + fade_in_ms: number; + fade_out_ms: number; + hold_ms: number; + master_limit: number; + values: Array<{ + channel: number; + value: number; + precedence: string; + source?: string; + }>; + targets: Array<{ + target_type: "fixture" | "group"; + patch_id?: number | null; + group_name?: string | null; + values: Array<{ + attribute: string; + value: number; + precedence?: string | null; + }>; + }>; + tags: string[]; + is_active: boolean; +}; + +export type PatchValidation = { + valid: boolean; + range: string; + end_address: number; + channel_count: number; + occupied_channels: number[]; + conflicts: Array<{ + type: string; + patch_id?: number; + name?: string; + range?: string; + fixture?: string; + message: string; + }>; +}; + +export type SystemCommandResult = { + accepted: boolean; + action: string; + status: string; + detail: string; +}; + +export type LiveMixerItem = { + id: string; + source_type: "patch" | "home_assistant"; + source_id: number; + patch_id: number | null; + name: string; + manufacturer: string | null; + model: string | null; + mode_key: string; + universe: number; + start_address: number; + end_address: number; + channel_count: number; + entity_id?: string | null; + status?: string | null; + last_sent_summary?: string | null; + channels: Array<{ + index: number; + absolute_channel: number; + key: string; + display_name: string; + precedence: string; + resolution: number; + value: number; + }>; +}; + +const jsonHeaders = { "Content-Type": "application/json" }; + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`/api/v1${path}`, init); + if (!response.ok) { + throw new Error(`API-fejl ${response.status}`); + } + return (await response.json()) as T; +} + +export const api = { + getStatus: () => request("/system/status"), + restartService: () => request("/system/restart-service", { method: "POST" }), + rebootHost: () => request("/system/reboot-host", { method: "POST" }), + getDmxConfig: () => request("/dmx/config"), + saveDmxConfig: (payload: Record) => + request("/dmx/config", { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + discoverArtNet: (timeoutS = 1) => + request<{ items: ArtNetNode[]; count: number }>(`/dmx/artnet/discover?timeout_s=${timeoutS}`, { + method: "POST" + }), + listDmxDevices: () => request<{ devices: DmxDevice[] }>("/dmx/devices"), + getHomeAssistantConfig: () => request("/integrations/home-assistant/config"), + saveHomeAssistantConfig: (payload: Record) => + request("/integrations/home-assistant/config", { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + testHomeAssistantConnection: () => + request("/integrations/home-assistant/test-connection", { + method: "POST" + }), + listHomeAssistantEntities: () => + request<{ items: HomeAssistantEntity[] }>("/integrations/home-assistant/entities"), + listHomeAssistantMappings: () => + request<{ items: HomeAssistantMapping[] }>("/integrations/home-assistant/mappings"), + createHomeAssistantMapping: (payload: Record) => + request("/integrations/home-assistant/mappings", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + updateHomeAssistantMapping: (mappingId: number, payload: Record) => + request(`/integrations/home-assistant/mappings/${mappingId}`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + deleteHomeAssistantMapping: (mappingId: number) => + request>(`/integrations/home-assistant/mappings/${mappingId}`, { method: "DELETE" }), + testHomeAssistantMapping: (mappingId: number) => + request>(`/integrations/home-assistant/mappings/${mappingId}/test`, { + method: "POST" + }), + listMidiBridges: () => request<{ items: MidiBridgeStatus[] }>("/integrations/midi/bridges"), + listMidiMappings: () => request<{ items: MidiMapping[] }>("/integrations/midi/mappings"), + createMidiMapping: (payload: Record) => + request("/integrations/midi/mappings", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + updateMidiMapping: (mappingId: number, payload: Record) => + request(`/integrations/midi/mappings/${mappingId}`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + deleteMidiMapping: (mappingId: number) => + request>(`/integrations/midi/mappings/${mappingId}`, { method: "DELETE" }), + testMidiMapping: (mappingId: number, value = 127) => + request>(`/integrations/midi/mappings/${mappingId}/test`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ value }) + }), + listMidiTokens: () => request<{ items: MidiBridgeToken[] }>("/integrations/midi/tokens"), + createMidiToken: (payload: Record) => + request("/integrations/midi/tokens", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + revokeMidiToken: (tokenId: number) => + request>(`/integrations/midi/tokens/${tokenId}`, { method: "DELETE" }), + getMidiLearnState: () => request("/integrations/midi/learn"), + startMidiLearn: (timeoutSeconds = 15, allowPassthrough = false) => + request("/integrations/midi/learn/start", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ timeout_seconds: timeoutSeconds, allow_passthrough: allowPassthrough }) + }), + cancelMidiLearn: () => + request("/integrations/midi/learn/cancel", { + method: "POST" + }), + getFrame: () => request<{ universe: number; values: number[]; sources: string[] }>("/dmx/frame"), + getFixtures: () => request<{ items: FixtureSummary[] }>("/fixtures"), + searchFixtures: (query: string) => + request<{ results: Array<{ fixture_key: string; cached: boolean }> }>( + `/fixtures/search-ofl?query=${encodeURIComponent(query)}`, + { method: "POST" } + ), + previewFixture: (manufacturerKey: string, fixtureKey: string) => + request( + `/fixtures/preview-ofl?manufacturer_key=${encodeURIComponent(manufacturerKey)}&fixture_key=${encodeURIComponent(fixtureKey)}`, + { method: "POST" } + ), + importFixture: (manufacturerKey: string, fixtureKey: string) => + request( + `/fixtures/import-ofl?manufacturer_key=${encodeURIComponent(manufacturerKey)}&fixture_key=${encodeURIComponent(fixtureKey)}`, + { method: "POST" } + ), + importFixtureFile: (payload: Record) => + request("/fixtures/import-file", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + getLiveMixer: () => request<{ items: LiveMixerItem[] }>("/live/mixer"), + setLiveMixerPatchValues: (patchId: number, values: Record) => + request<{ items: LiveMixerItem[] }>(`/live/mixer/${patchId}`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ values }) + }), + setLiveMixerHomeAssistantValues: (mappingId: number, values: Record) => + request<{ items: LiveMixerItem[] }>(`/live/mixer/home-assistant/${mappingId}`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ values }) + }), + clearLiveMixerPatchValues: (patchId: number) => + request<{ items: LiveMixerItem[] }>(`/live/mixer/${patchId}`, { method: "DELETE" }), + clearLiveMixerHomeAssistantValues: (mappingId: number) => + request<{ items: LiveMixerItem[] }>(`/live/mixer/home-assistant/${mappingId}`, { method: "DELETE" }), + listPatches: () => request<{ items: PatchItem[] }>("/patch"), + validatePatch: (payload: Record) => + request("/patch/validate", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + createPatch: (payload: Record) => + request("/patch", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + updatePatch: (patchId: number, payload: Record) => + request(`/patch/${patchId}`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + deletePatch: (patchId: number) => request>(`/patch/${patchId}`, { method: "DELETE" }), + listScenes: () => request<{ items: SceneItem[] }>("/scenes"), + createScene: (payload: Record) => + request("/scenes", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + activateScene: (slug: string) => + request(`/scenes/${encodeURIComponent(slug)}/activate`, { method: "POST" }), + releaseScene: (slug: string) => + request>(`/scenes/${encodeURIComponent(slug)}/release`, { method: "POST" }), + deleteScene: (sceneId: number) => + request>(`/scenes/${sceneId}`, { method: "DELETE" }), + listEffects: () => request<{ items: Array> }>("/effects"), + createEffect: (payload: Record) => + request>("/effects", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify(payload) + }), + triggerEffect: (slug: string) => + request>(`/effects/${encodeURIComponent(slug)}/trigger`, { method: "POST" }), + stopEffect: (slug: string) => + request>(`/effects/${encodeURIComponent(slug)}/stop`, { method: "POST" }), + tapBpm: () => request>("/bpm/tap", { method: "POST" }), + setManualBpm: (bpm: number) => + request>(`/bpm/manual?bpm=${bpm}`, { method: "POST" }), + startAudioBpm: (device: string) => + request("/bpm/audio/start", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ device }) + }), + stopAudioBpm: () => + request>("/bpm/audio/stop", { method: "POST" }), + listBpmDevices: () => request<{ devices: BpmDevice[] }>("/bpm/devices"), + getBpmConfig: () => request("/bpm/config"), + saveBpmConfig: (preferredDevice: string) => + request("/bpm/config", { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ preferred_device: preferredDevice }) + }), + blackout: () => request>("/dmx/blackout", { method: "POST" }), + releaseBlackout: () => request>("/dmx/release-blackout", { method: "POST" }), + listBackups: () => request<{ items: Array> }>("/backups"), + createBackup: () => request>("/backups", { method: "POST" }), + restoreBackup: (backupId: string) => + request>(`/backups/${encodeURIComponent(backupId)}/restore`, { + method: "POST" + }), + triggerMixitup: (slug: string, payload: Record) => + request>(`/triggers/${encodeURIComponent(slug)}`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify(payload) + }) +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ca3d5f9 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); + diff --git a/frontend/src/pages/BackupPage.tsx b/frontend/src/pages/BackupPage.tsx new file mode 100644 index 0000000..9eecae7 --- /dev/null +++ b/frontend/src/pages/BackupPage.tsx @@ -0,0 +1,40 @@ +type BackupPageProps = { + backups: Array>; + onCreate: () => Promise; + onRestore: (backupId: string) => Promise; +}; + +export function BackupPage({ backups, onCreate, onRestore }: BackupPageProps) { + return ( +
+
+

Backup og restore

+

+ Release 1.0 inkluderer backup-endpoints, Linux-opdateringsflow og dokumenteret pre-restore + backup. +

+
+
+ Bemærk +
+ Restore og download skal altid verificeres mod den rigtige Debian-installation. Denne + session påstår ikke direkte fysisk hardwaremåling. +
+
+
+ +
+
+ {backups.map((backup) => ( +
+ {String(backup.label)} +
{String(backup.created_at ?? "ukendt tidspunkt")}
+
+ +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/BpmPage.tsx b/frontend/src/pages/BpmPage.tsx new file mode 100644 index 0000000..0b2747c --- /dev/null +++ b/frontend/src/pages/BpmPage.tsx @@ -0,0 +1,478 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +import { BpmDevice, BpmStatus, PatchItem } from "../lib/api"; + +type BpmPageProps = { + status: BpmStatus | null; + devices: BpmDevice[]; + patches: PatchItem[]; + onTap: () => Promise; + onSetManual: (bpm: number) => Promise; + onStartAudio: (device: string) => Promise; + onStopAudio: () => Promise; + onCreateEffect: (payload: Record) => Promise; + onTriggerEffect: (slug: string) => Promise; + onStopEffect: (slug: string) => Promise; +}; + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function BpmPage({ + status, + devices, + patches, + onTap, + onSetManual, + onStartAudio, + onStopAudio, + onCreateEffect, + onTriggerEffect, + onStopEffect +}: BpmPageProps) { + const [manualBpm, setManualBpm] = useState(status?.bpm ?? 120); + const [selectedDevice, setSelectedDevice] = useState( + status?.selected_device && status.selected_device !== "manual" + ? status.selected_device + : (devices[0]?.id ?? "alsa:auto") + ); + const [targetMode, setTargetMode] = useState<"fixture" | "group">("fixture"); + const [selectedPatchId, setSelectedPatchId] = useState(patches[0]?.id ?? 0); + const [selectedGroupName, setSelectedGroupName] = useState(""); + const [selectedAttribute, setSelectedAttribute] = useState(""); + const [pulseValue, setPulseValue] = useState(255); + const [pulseDurationMs, setPulseDurationMs] = useState(180); + const [syncMessage, setSyncMessage] = useState(null); + const [displayLevel, setDisplayLevel] = useState(status?.input_level ?? 0); + const [displayPeak, setDisplayPeak] = useState(status?.peak_level ?? 0); + const levelTargetRef = useRef(status?.input_level ?? 0); + const peakTargetRef = useRef(status?.peak_level ?? 0); + + useEffect(() => { + if (status?.selected_device && status.selected_device !== "manual") { + setSelectedDevice(status.selected_device); + } + }, [status?.selected_device]); + + useEffect(() => { + if (!patches.length) { + setSelectedPatchId(0); + return; + } + if (!patches.some((patch) => patch.id === selectedPatchId)) { + setSelectedPatchId(patches[0].id); + } + }, [patches, selectedPatchId]); + + const groupOptions = useMemo( + () => Array.from(new Set(patches.flatMap((patch) => patch.group_names))).sort((left, right) => left.localeCompare(right)), + [patches] + ); + + useEffect(() => { + if (!selectedGroupName && groupOptions[0]) { + setSelectedGroupName(groupOptions[0]); + } + if (selectedGroupName && !groupOptions.includes(selectedGroupName)) { + setSelectedGroupName(groupOptions[0] ?? ""); + } + }, [groupOptions, selectedGroupName]); + + const selectedPatch = useMemo( + () => patches.find((patch) => patch.id === selectedPatchId) ?? patches[0] ?? null, + [patches, selectedPatchId] + ); + + const targetPatches = useMemo(() => { + if (targetMode === "fixture") { + return selectedPatch ? [selectedPatch] : []; + } + return patches.filter((patch) => patch.group_names.includes(selectedGroupName)); + }, [patches, selectedGroupName, selectedPatch, targetMode]); + + const attributeOptions = useMemo( + () => + Array.from( + new Set( + targetPatches.flatMap((patch) => + patch.channels.map((channel) => channel.display_name) + ) + ) + ), + [targetPatches] + ); + + useEffect(() => { + if (!attributeOptions.length) { + setSelectedAttribute(""); + return; + } + if (!selectedAttribute || !attributeOptions.includes(selectedAttribute)) { + setSelectedAttribute(attributeOptions[0]); + } + }, [attributeOptions, selectedAttribute]); + + useEffect(() => { + levelTargetRef.current = status?.input_level ?? 0; + peakTargetRef.current = status?.peak_level ?? 0; + }, [status?.input_level, status?.peak_level]); + + useEffect(() => { + let frameId = 0; + const animate = () => { + setDisplayLevel((current) => { + const next = current + (levelTargetRef.current - current) * 0.42; + return Math.abs(next - levelTargetRef.current) < 0.003 ? levelTargetRef.current : next; + }); + setDisplayPeak((current) => { + const next = current + (peakTargetRef.current - current) * 0.28; + return Math.abs(next - peakTargetRef.current) < 0.003 ? peakTargetRef.current : next; + }); + frameId = window.requestAnimationFrame(animate); + }; + frameId = window.requestAnimationFrame(animate); + return () => window.cancelAnimationFrame(frameId); + }, []); + + const syncSlug = useMemo(() => { + const targetKey = + targetMode === "fixture" + ? selectedPatch?.name ?? "fixture" + : selectedGroupName || "group"; + return slugify(`bpm-sync-${targetMode}-${targetKey}-${selectedAttribute || "channel"}`); + }, [selectedAttribute, selectedGroupName, selectedPatch?.name, targetMode]); + + const syncTargets = useMemo(() => { + const channels = Object.fromEntries( + targetPatches.flatMap((patch) => { + const channel = patch.channels.find((item) => item.display_name === selectedAttribute); + if (!channel) { + return []; + } + const absoluteChannel = patch.start_address + channel.index - 1; + return [[absoluteChannel, pulseValue]]; + }) + ); + const precedence = Object.fromEntries( + targetPatches.flatMap((patch) => { + const channel = patch.channels.find((item) => item.display_name === selectedAttribute); + if (!channel) { + return []; + } + const absoluteChannel = patch.start_address + channel.index - 1; + return [[absoluteChannel, channel.precedence]]; + }) + ); + return { channels, precedence }; + }, [pulseValue, selectedAttribute, targetPatches]); + + async function handleSaveAndStartSync(): Promise { + if (!selectedAttribute || !targetPatches.length || Object.keys(syncTargets.channels).length === 0) { + setSyncMessage("Vælg mindst én patched lampe og en gyldig attribut før BPM-sync kan startes."); + return; + } + await onCreateEffect({ + name: + targetMode === "fixture" + ? `BPM sync · ${selectedPatch?.name ?? "Fixture"} · ${selectedAttribute}` + : `BPM sync · ${selectedGroupName} · ${selectedAttribute}`, + slug: syncSlug, + effect_type: "beat-flash", + priority: 60, + cooldown_ms: 0, + parameters: { + duration_ms: pulseDurationMs, + channels: syncTargets.channels, + precedence: syncTargets.precedence, + source: "bpm" + } + }); + await onTriggerEffect(syncSlug); + setSyncMessage( + targetMode === "fixture" + ? `BPM-sync kører nu på ${selectedPatch?.name ?? "fixture"} / ${selectedAttribute}.` + : `BPM-sync kører nu på gruppe ${selectedGroupName} / ${selectedAttribute}.` + ); + } + + async function handleStopSync(): Promise { + await onStopEffect(syncSlug); + setSyncMessage("BPM-sync er stoppet for det valgte target."); + } + + return ( +
+
+

Lyd og BPM

+

+ BPM kan drives af manuel værdi, tap-tempo eller rigtig audio-input via ALSA. Ved tab af + input falder systemet tilbage til manuel BPM. +

+
+ +
+
+
+ BPM + {status?.bpm ?? 0} +
+
+ Confidence + {Math.round((status?.confidence ?? 0) * 100)}% +
+
+ Kilde + {status?.mode ?? "ukendt"} +
+
+ Input + {status?.audio_connected ? "Live" : "Standby"} +
+
+ Level + {Math.round((status?.input_level ?? 0) * 100)}% +
+
+ Aktiv device + {status?.audio_connected ? status.current_device.replace(/^alsa:/, "") : "Ingen"} +
+
+
+ +
+
+

Manual og tap

+ +
+ + +
+
+ +
+

Audio-input

+
+
+ VU-meter + + {status?.clipping ? "Clip" : status?.audio_connected ? "Signal" : "Ingen signal"} + +
+
+
+
+
+
+ 0 + 25 + 50 + 75 + 100 +
+
+ Niveau {Math.round(displayLevel * 100)}% · Peak {Math.round(displayPeak * 100)}% +
+
+ +
+ Valgt som standard: {(status?.selected_device ?? "alsa:auto").replace(/^alsa:/, "")} + {" · "} + Aktivt input: {status?.audio_connected ? status.current_device.replace(/^alsa:/, "") : "ingen"} +
+
+ {status?.audio_connected ? ( + <> + + + + ) : ( + <> + + + + )} +
+ {status?.last_error ? ( +
+ Seneste fejl +
{status.last_error}
+
+ ) : ( +
+ Input åbnes som {status?.format ?? "S16_LE"} · {status?.sample_rate ?? 44100} Hz ·{" "} + {status?.channels ?? 1} kanal. +
+ )} +
+
+ +
+
+

BPM-synk til lamper

+

+ Her binder du BPM direkte til patched fixtures eller en hel gruppe. Vælg fx dimmer, + strobe eller farve, og start sync uden at hoppe over i andre sider. +

+
+ + {targetMode === "fixture" ? ( + + ) : ( + + )} + + + +
+
+ + +
+ {syncMessage ?
{syncMessage}
: null} +
+ +
+

Aktivt BPM-target

+
+ {targetMode === "fixture" ? selectedPatch?.name ?? "Ingen fixture" : selectedGroupName || "Ingen gruppe"} +
+ {targetMode === "fixture" + ? `1 lampe valgt` + : `${targetPatches.length} lamper i gruppen`} + {" · "} + Attribut {selectedAttribute || "ikke valgt"} +
+
+
+ {targetPatches.map((patch) => { + const channel = patch.channels.find((item) => item.display_name === selectedAttribute); + return ( +
+ {patch.name} +
+ {channel + ? `${selectedAttribute} · DMX ${patch.start_address + channel.index - 1} · ${channel.precedence.toUpperCase()}` + : `${selectedAttribute || "Attribut"} findes ikke på denne fixture`} +
+
+ ); + })} +
+
+
+ +
+ Tilgængelige devices +
+ {devices.map((device) => ( +
+ {device.name} +
+ {device.id} · backend {device.backend} + {device.is_default ? " · default" : ""} + {device.recommended ? " · anbefalet" : ""} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..9b64c32 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; + +import { SystemStatus } from "../lib/api"; +import { StatusPill } from "../components/StatusPill"; + +type DashboardPageProps = { + status: SystemStatus | null; + onBlackout: () => void; + onReleaseBlackout: () => void; + onRestartService: () => Promise; + onRebootHost: () => Promise; +}; + +export function DashboardPage({ + status, + onBlackout, + onReleaseBlackout, + onRestartService, + onRebootHost +}: DashboardPageProps) { + const [systemMessage, setSystemMessage] = useState(null); + + if (!status) { + return
Indlæser systemstatus…
; + } + + const tone = status.engine.blackout + ? "danger" + : status.engine.degraded || !status.engine.connected + ? "warning" + : "success"; + + return ( + <> +
+
+
+ + + + +
+

Operations-dashboard

+

+ Softwaren viser beregnet og afleveret frame. Den beviser ikke alene fysisk DMX-signal + ved lampen. +

+
+ + + + +
+ {systemMessage ?
{systemMessage}
: null} +
+
+

Førstegangsopsætning

+
+
+ 1. Administrator +
Opret lokal admin, aktiver session og CSRF.
+
+
+ 2. DMX/OLA +
Vælg universe, port og kør en tidsbegrænset kanaltest.
+
+
+ 3. Sikkerhed +
Master-limit, strobegrænser, safe scene og rate limiting.
+
+
+ 4. MixItUp +
Generér token og kopier færdige web-request data.
+
+
+
+
+ +
+
+ Frame rate +
{status.engine.fps}
+
+
+ CPU +
{status.telemetry.cpu_percent}%
+
+
+ RAM +
{status.telemetry.ram_percent}%
+
+
+ Oppetid +
{status.telemetry.uptime_seconds}s
+
+
+ + ); +} diff --git a/frontend/src/pages/EffectsPage.tsx b/frontend/src/pages/EffectsPage.tsx new file mode 100644 index 0000000..9a45007 --- /dev/null +++ b/frontend/src/pages/EffectsPage.tsx @@ -0,0 +1,206 @@ +import { FormEvent, useEffect, useMemo, useState } from "react"; + +import { PatchItem } from "../lib/api"; + +type EffectsPageProps = { + effects: Array>; + patches: PatchItem[]; + onCreate: (payload: Record) => Promise; + onTrigger: (slug: string) => Promise; + onStop: (slug: string) => Promise; +}; + +function describeEffect(effect: Record): string { + const parameters = effect.parameters; + if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { + return "Ingen parametre"; + } + const typedParameters = parameters as Record; + const duration = typedParameters.duration_ms; + const channels = typedParameters.channels; + const channelCount = channels && typeof channels === "object" && !Array.isArray(channels) + ? Object.keys(channels).length + : 0; + return `Varighed ${duration ?? 0} ms · ${channelCount} kanal(er)`; +} + +export function EffectsPage({ effects, patches, onCreate, onTrigger, onStop }: EffectsPageProps) { + const [name, setName] = useState("Raid flash"); + const [slug, setSlug] = useState("raid-flash"); + const [patchId, setPatchId] = useState(patches[0]?.id ?? 0); + const [channelIndex, setChannelIndex] = useState(patches[0]?.channels[0]?.index ?? 1); + const [value, setValue] = useState(255); + const [durationMs, setDurationMs] = useState(800); + + const selectedPatch = useMemo( + () => patches.find((patch) => patch.id === patchId) ?? null, + [patchId, patches] + ); + const selectedChannel = useMemo( + () => selectedPatch?.channels.find((channel) => channel.index === channelIndex) ?? selectedPatch?.channels[0] ?? null, + [channelIndex, selectedPatch] + ); + + useEffect(() => { + if (patches.length === 0) { + setPatchId(0); + return; + } + if (!patches.some((patch) => patch.id === patchId)) { + setPatchId(patches[0].id); + } + }, [patchId, patches]); + + useEffect(() => { + if (selectedPatch === null) { + return; + } + if (!selectedPatch.channels.some((channel) => channel.index === channelIndex)) { + setChannelIndex(selectedPatch.channels[0]?.index ?? 1); + } + }, [channelIndex, selectedPatch]); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (selectedPatch === null || selectedChannel === null) { + return; + } + const absoluteChannel = selectedPatch.start_address + selectedChannel.index - 1; + await onCreate({ + name, + slug, + effect_type: "beat-flash", + priority: 60, + cooldown_ms: 10000, + parameters: { + duration_ms: durationMs, + patch_id: selectedPatch.id, + channels: { [absoluteChannel]: value }, + precedence: { [absoluteChannel]: selectedChannel.precedence } + } + }); + } + + return ( +
+
+

Effekter og chasers

+

+ Beat-flash kan nu følge BPM live. Vælg en patched fixture og en kanal som fx dimmer, + strobe eller farve, gem effekten og trig den for at lade lampen pulse i takt. +

+
+ +
+
+

Ny effekt

+ {patches.length === 0 ? ( +
Du skal patch’e mindst én lampe før du kan bygge en effekt.
+ ) : null} +
+ + + + + + +
+
+ +
+
+ +
+

Aktuelt target

+ {selectedPatch && selectedChannel ? ( + <> +
+ {selectedPatch.name} · {selectedPatch.mode_key} · {selectedPatch.start_address}-{selectedPatch.end_address} +
+
+ {selectedChannel.display_name} +
+ Kanal {selectedChannel.index} · DMX {selectedPatch.start_address + selectedChannel.index - 1} ·{" "} + {selectedChannel.precedence.toUpperCase()} +
+
+ + ) : ( +
Vælg først en patched fixture.
+ )} +
+
+ +
+ {effects.map((effect) => ( +
+
+ {String(effect.effect_type)} +

{String(effect.name)}

+
+ + +
+
+
+ Indstillinger +
{describeEffect(effect)}
+
+ Slug +
{String(effect.slug)}
+
+
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/FixturesPage.tsx b/frontend/src/pages/FixturesPage.tsx new file mode 100644 index 0000000..d3930ad --- /dev/null +++ b/frontend/src/pages/FixturesPage.tsx @@ -0,0 +1,266 @@ +import { ChangeEvent, FormEvent, useMemo, useState } from "react"; + +import { FixturePreview, FixtureSummary } from "../lib/api"; + +type FixturesPageProps = { + fixtures: FixtureSummary[]; + results: Array<{ fixture_key: string; cached: boolean }>; + onSearch: (query: string) => Promise; + onPreview: (manufacturerKey: string, fixtureKey: string) => Promise; + onImport: (manufacturerKey: string, fixtureKey: string) => Promise; + onImportFile: (payload: Record) => Promise; + onConfigureFixture: (fixtureId: number) => void; +}; + +export function FixturesPage({ + fixtures, + results, + onSearch, + onPreview, + onImport, + onImportFile, + onConfigureFixture +}: FixturesPageProps) { + const [query, setQuery] = useState("showtec phantom"); + const [preview, setPreview] = useState(null); + const [selectedMode, setSelectedMode] = useState(""); + const [filePayload, setFilePayload] = useState(""); + const [manufacturerName, setManufacturerName] = useState(""); + const [manufacturerKey, setManufacturerKey] = useState(""); + const [fixtureKey, setFixtureKey] = useState(""); + const [fileStatus, setFileStatus] = useState(null); + const [searchStatus, setSearchStatus] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + await onSearch(query); + } + + async function handleFileSelect(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) { + return; + } + const text = await file.text(); + setFilePayload(text); + setFileStatus(`Fil indlæst: ${file.name}`); + if (!fixtureKey) { + setFixtureKey(file.name.replace(/\.json$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-")); + } + } + + async function handleFileImport(event: FormEvent) { + event.preventDefault(); + try { + const parsed = JSON.parse(filePayload) as Record; + const sourceValue = parsed.source; + const source = + sourceValue && typeof sourceValue === "object" && !Array.isArray(sourceValue) + ? { ...(sourceValue as Record) } + : {}; + if (manufacturerKey.trim()) { + source.manufacturer_key = manufacturerKey.trim(); + } + if (fixtureKey.trim()) { + source.fixture_key = fixtureKey.trim(); + } + if (Object.keys(source).length > 0) { + parsed.source = source; + } + if (manufacturerName.trim() && !parsed.manufacturer) { + parsed.manufacturer = manufacturerName.trim(); + } + const imported = await onImportFile(parsed); + setPreview(imported); + setSelectedMode(imported.modes[0]?.key ?? ""); + setFileStatus(`Fixture importeret: ${imported.manufacturer} / ${imported.model}`); + onConfigureFixture(imported.id); + } catch { + setFileStatus("Kunne ikke importere fixture-filen. Kontroller at JSON-indholdet er gyldigt."); + } + } + + const activeMode = useMemo( + () => preview?.modes.find((mode) => mode.key === selectedMode) ?? preview?.modes[0] ?? null, + [preview, selectedMode] + ); + + return ( +
+
+

Fixtures og OFL

+

+ Søg i OFL direkte fra UI, behold importen lokalt og behold offline fallback via cache. +

+

+ Når en fixture er importeret, kan den sendes direkte videre til patch-konfiguration. +

+
+ +
+ +
+ +
+
+ +
+ + + + +