From 0f6a0a38f8a5741391864d2be50b42ba6319142a Mon Sep 17 00:00:00 2001 From: "Justin J. Novack" Date: Sat, 1 Aug 2026 11:18:25 -0400 Subject: [PATCH 01/11] feat: api/v2 --- etc/apache2.site | 11 +- tests/playwright/API_COVERAGE.md | 243 + tests/playwright/fixtures/playback.ts | 25 + tests/playwright/fixtures/version.ts | 40 + tests/playwright/package.json | 14 +- tests/playwright/playwright.config.ts | 61 +- .../playwright/scripts/check-api-coverage.ts | 148 + tests/playwright/tests/api-v2/audio.spec.ts | 25 + tests/playwright/tests/api-v2/backups.spec.ts | 91 + tests/playwright/tests/api-v2/cape.spec.ts | 89 + tests/playwright/tests/api-v2/channel.spec.ts | 61 + tests/playwright/tests/api-v2/compat.spec.ts | 27 + .../tests/api-v2/configfile.spec.ts | 34 + tests/playwright/tests/api-v2/dir.spec.ts | 16 + tests/playwright/tests/api-v2/effects.spec.ts | 23 + tests/playwright/tests/api-v2/email.spec.ts | 19 + tests/playwright/tests/api-v2/events.spec.ts | 27 + tests/playwright/tests/api-v2/file.spec.ts | 141 + tests/playwright/tests/api-v2/files.spec.ts | 23 + tests/playwright/tests/api-v2/git.spec.ts | 56 + tests/playwright/tests/api-v2/media.spec.ts | 27 + tests/playwright/tests/api-v2/network.spec.ts | 116 + tests/playwright/tests/api-v2/options.spec.ts | 19 + .../playwright/tests/api-v2/pipewire.spec.ts | 409 + .../playwright/tests/api-v2/playlist.spec.ts | 82 + .../playwright/tests/api-v2/playlists.spec.ts | 92 + tests/playwright/tests/api-v2/plugin.spec.ts | 77 + tests/playwright/tests/api-v2/proxies.spec.ts | 64 + tests/playwright/tests/api-v2/remotes.spec.ts | 27 + .../playwright/tests/api-v2/schedule.spec.ts | 35 + tests/playwright/tests/api-v2/scripts.spec.ts | 51 + .../playwright/tests/api-v2/sequence.spec.ts | 57 + .../playwright/tests/api-v2/settings.spec.ts | 52 + .../tests/api-v2/statistics.spec.ts | 28 + tests/playwright/tests/api-v2/system.spec.ts | 120 + .../playwright/tests/api-v2/testmode.spec.ts | 27 + tests/playwright/tests/api-v2/time.spec.ts | 19 + www/api/MIGRATION.md | 100 + www/api/README.md | 35 +- www/api/api.html | 31 - www/api/api.php | 28 +- www/api/controllers/audioaliases.php | 14 +- www/api/controllers/backups.php | 38 +- www/api/controllers/cape.php | 20 +- www/api/controllers/channel.php | 16 +- www/api/controllers/configfile.php | 6 +- www/api/controllers/events.php | 8 +- www/api/controllers/files.php | 78 +- www/api/controllers/git.php | 4 +- www/api/controllers/helpers.php | 41 + www/api/controllers/network.php | 50 +- www/api/controllers/pipewire.php | 133 +- www/api/controllers/playlist.php | 68 +- www/api/controllers/plugin.php | 16 +- www/api/controllers/proxies.php | 65 +- www/api/controllers/scripts.php | 16 +- www/api/controllers/sequence.php | 20 +- www/api/controllers/settings.php | 2 +- www/api/controllers/system.php | 19 +- www/api/ht.access | 10 +- www/api/index.php | 682 +- www/api/tools/build_docs.sh | 6 +- www/api/tools/generate_openapi.py | 56 +- www/api/tools/generate_openapi_v2.py | 388 + www/api/v1/api.html | 36 + www/api/{ => v1}/openapi.json | 49 +- www/api/v2/api.html | 36 + www/api/v2/openapi.json | 6931 +++++++++++++++++ 68 files changed, 10832 insertions(+), 646 deletions(-) create mode 100644 tests/playwright/API_COVERAGE.md create mode 100644 tests/playwright/fixtures/playback.ts create mode 100644 tests/playwright/fixtures/version.ts create mode 100644 tests/playwright/scripts/check-api-coverage.ts create mode 100644 tests/playwright/tests/api-v2/audio.spec.ts create mode 100644 tests/playwright/tests/api-v2/backups.spec.ts create mode 100644 tests/playwright/tests/api-v2/cape.spec.ts create mode 100644 tests/playwright/tests/api-v2/channel.spec.ts create mode 100644 tests/playwright/tests/api-v2/compat.spec.ts create mode 100644 tests/playwright/tests/api-v2/configfile.spec.ts create mode 100644 tests/playwright/tests/api-v2/dir.spec.ts create mode 100644 tests/playwright/tests/api-v2/effects.spec.ts create mode 100644 tests/playwright/tests/api-v2/email.spec.ts create mode 100644 tests/playwright/tests/api-v2/events.spec.ts create mode 100644 tests/playwright/tests/api-v2/file.spec.ts create mode 100644 tests/playwright/tests/api-v2/files.spec.ts create mode 100644 tests/playwright/tests/api-v2/git.spec.ts create mode 100644 tests/playwright/tests/api-v2/media.spec.ts create mode 100644 tests/playwright/tests/api-v2/network.spec.ts create mode 100644 tests/playwright/tests/api-v2/options.spec.ts create mode 100644 tests/playwright/tests/api-v2/pipewire.spec.ts create mode 100644 tests/playwright/tests/api-v2/playlist.spec.ts create mode 100644 tests/playwright/tests/api-v2/playlists.spec.ts create mode 100644 tests/playwright/tests/api-v2/plugin.spec.ts create mode 100644 tests/playwright/tests/api-v2/proxies.spec.ts create mode 100644 tests/playwright/tests/api-v2/remotes.spec.ts create mode 100644 tests/playwright/tests/api-v2/schedule.spec.ts create mode 100644 tests/playwright/tests/api-v2/scripts.spec.ts create mode 100644 tests/playwright/tests/api-v2/sequence.spec.ts create mode 100644 tests/playwright/tests/api-v2/settings.spec.ts create mode 100644 tests/playwright/tests/api-v2/statistics.spec.ts create mode 100644 tests/playwright/tests/api-v2/system.spec.ts create mode 100644 tests/playwright/tests/api-v2/testmode.spec.ts create mode 100644 tests/playwright/tests/api-v2/time.spec.ts create mode 100644 www/api/MIGRATION.md delete mode 100644 www/api/api.html create mode 100644 www/api/controllers/helpers.php create mode 100644 www/api/tools/generate_openapi_v2.py create mode 100644 www/api/v1/api.html rename www/api/{ => v1}/openapi.json (99%) create mode 100644 www/api/v2/api.html create mode 100644 www/api/v2/openapi.json diff --git a/etc/apache2.site b/etc/apache2.site index b889c780f..b5df85f10 100644 --- a/etc/apache2.site +++ b/etc/apache2.site @@ -118,14 +118,21 @@ ServerTokens Prod # Static and redirect rules RewriteRule ^$ /api/index.php?uri=/ [L] + RewriteRule ^v1/?$ /api/index.php?uri=/v1/ [NC,L,QSA,B] + RewriteRule ^v1/api\.html$ /api/index.php?uri=/v1/api.html [NC,L,QSA,B] + RewriteRule ^v1/openapi\.(json|yaml)$ /api/index.php?uri=/v1/openapi.$1 [NC,L,QSA,B] + RewriteRule ^v2/?$ /api/index.php?uri=/v2/ [NC,L,QSA,B] + RewriteRule ^v2/api\.html$ /api/index.php?uri=/v2/api.html [NC,L,QSA,B] + RewriteRule ^v2/openapi\.(json|yaml)$ /api/index.php?uri=/v2/openapi.$1 [NC,L,QSA,B] RewriteRule ^index.php - [L,NC] RewriteRule ^api.php - [L,NC] - RewriteRule ^api.html - [L,NC] - RewriteRule ^openapi.json - [L,NC] RewriteRule ^help ../api/ [R=301,L,NC] RewriteRule ^help/.* ../api/ [R=301,L,NC] RewriteRule ^endpoints.json - [L,NC] + # v2 API routes — served by the merged router + RewriteRule ^v2/(.*)$ /api/index.php?uri=/v2/$1 [NC,L,QSA,B] + # Catch-all rewrite to index.php RewriteRule ^(.*)$ /api/index.php?uri=/$1 [NC,L,QSA,B] diff --git a/tests/playwright/API_COVERAGE.md b/tests/playwright/API_COVERAGE.md new file mode 100644 index 000000000..af467a3c4 --- /dev/null +++ b/tests/playwright/API_COVERAGE.md @@ -0,0 +1,243 @@ +# API v2 Test Coverage + +This document is the source of truth for route coverage classification. The `check-api-coverage` script diffs this file against the router to detect gaps. + +## Tier Legend + +| Tier | CI | Manual (container) | Manual (hardware) | Description | +| --- | --- | --- | --- | --- | +| FULL | ✓ | ✓ | ✓ | Deterministic — structure and values assertable | +| SCHEMA | ✓ | ✓ | ✓ | Shape assertable; values are runtime-dependent | +| STATE | — | ✓ | ✓ | Test owns its setup/teardown | +| HARDWARE | — | — | ✓ | Requires specific physical hardware | +| DESTRUCTIVE | — | opt-in | opt-in | Kills fppd, reboots, or shuts down the device | + +## Hardware Tag Reference + +| Tag | Required hardware | +| --- | --- | +| `@hardware:cape` | Physical cape with EEPROM (Pi/BBB only) | +| `@hardware:wifi` | WiFi network interface | +| `@hardware:audio` | Audio device (sound card, USB audio) | +| `@hardware:pipewire` | PipeWire daemon running with connected audio/video hardware | + +## Routes + +| Method | Path | Tier | Tag | Notes | +| --- | --- | --- | --- | --- | +| GET | /backups/list | SCHEMA | | Returns array of available backups | +| GET | /backups/list/:DeviceName | STATE | @state | Requires mounted external device | +| GET | /backups/devices | SCHEMA | | Returns array of backup devices | +| POST | /backups/devices/mount/:DeviceName/:MountLocation | STATE | @state | Mounts a device | +| POST | /backups/devices/unmount/:DeviceName/:MountLocation | STATE | @state | Unmounts a device | +| POST | /backups/configuration | STATE | @state | Creates a backup; cleanup: delete it | +| GET | /backups/configuration/list | SCHEMA | | Returns array of backups | +| GET | /backups/configuration/list/:DeviceName | STATE | @state | Requires mounted device | +| POST | /backups/configuration/restore/:Directory/:BackupFilename | STATE | @state | Requires backup to exist first | +| GET | /backups/configuration/:Directory/:BackupFilename | STATE | @state | Requires backup to exist | +| DELETE | /backups/configuration/:Directory/:BackupFilename | STATE | @state | Self-contained with create first | +| GET | /cape | HARDWARE | @hardware:cape | Requires physical cape | +| POST | /cape/eeprom/voucher | HARDWARE | @hardware:cape | Requires physical cape | +| POST | /cape/eeprom/sign/:key/:order | HARDWARE | @hardware:cape | Requires physical cape | +| GET | /cape/eeprom/signingData/:key/:order | HARDWARE | @hardware:cape | Requires physical cape | +| GET | /cape/eeprom/signingFile/:key/:order | HARDWARE | @hardware:cape | Requires physical cape | +| POST | /cape/eeprom/signingData | HARDWARE | @hardware:cape | Requires physical cape | +| GET | /cape/options | SCHEMA | | May return empty object without cape | +| GET | /cape/strings | SCHEMA | | May return empty array without cape | +| GET | /cape/panel | SCHEMA | | May return empty array without cape | +| GET | /cape/strings/:key | HARDWARE | @hardware:cape | Requires physical cape | +| GET | /cape/panel/:key | HARDWARE | @hardware:cape | Requires physical cape | +| GET | /channel/input/stats | SCHEMA | | Object with numeric counters | +| DELETE | /channel/input/stats | STATE | @state | Resets stats | +| GET | /channel/output/processors | SCHEMA | | Returns array | +| POST | /channel/output/processors | STATE | @state | Saves output processors | +| GET | /channel/output/:file | SCHEMA | | Use `channeloutputs` as file param | +| POST | /channel/output/:file | STATE | @state | Saves channel output config | +| GET | /configfile | SCHEMA | | Returns array of directory names | +| GET | /configfile/** | SCHEMA | | Use a known path like `settings` | +| POST | /configfile/** | STATE | @state | Upload then delete | +| DELETE | /configfile/** | STATE | @state | Self-contained with upload first | +| POST | /dir/:DirName/:SubDir | STATE | @state | Create sequences/ci-test-dir | +| DELETE | /dir/:DirName/:SubDir | STATE | @state | Self-contained with create first | +| GET | /effects | SCHEMA | | Returns array | +| GET | /effects/ALL | SCHEMA | | Returns more items than /effects | +| POST | /email/configure | STATE | @state | Sets email config | +| POST | /email/test | STATE | @state | Sends actual email | +| GET | /events | SCHEMA | | Returns array | +| GET | /events/:eventId | STATE | @state | Requires event file to exist | +| POST | /events/:eventId/trigger | STATE | @state | Requires event to exist | +| GET | /files/:DirName | SCHEMA | | Use `sequences` as DirName | +| GET | /files/zip/:DirNames | SCHEMA | | Binary zip response | +| GET | /file/info/:plugin/:ext/** | STATE | @state | Requires plugin with a file | +| POST | /file/onUpload/:ext/** | STATE | @state | Requires plugin | +| POST | /file/move/:fileName | STATE | @state | Requires file to exist | +| POST | /file/:DirName/copy/:source/:dest | STATE | @state | Self-contained | +| POST | /file/:DirName/rename/:source/:dest | STATE | @state | Self-contained | +| GET | /file/:DirName/tailfollow/** | STATE | @state | Streaming endpoint | +| GET | /file/:DirName/** | STATE | @state | Requires file to exist | +| DELETE | /file/:DirName/** | STATE | @state | Self-contained with upload first | +| POST | /file/:DirName | STATE | @state | File upload | +| PATCH | /file/:DirName | STATE | @state | File patch (alias of POST) | +| POST | /file/:DirName/:Name | STATE | @state | File upload with explicit name | +| GET | /git/originLog | SCHEMA | | Returns array of commits | +| GET | /git/releases/os/:All | SCHEMA | | Returns array; use `0` as param | +| GET | /git/releases/sizes | SCHEMA | | Returns object | +| GET | /git/status | SCHEMA | | Returns object with branch info | +| GET | /git/branches | SCHEMA | | Returns array | +| POST | /git/reset | DESTRUCTIVE | @destructive | Resets git state | +| GET | /media | SCHEMA | | Returns array | +| GET | /media/:MediaName/duration | STATE | @state | Requires a media file | +| GET | /media/:MediaName/meta | STATE | @state | Requires a media file | +| GET | /network/dns | SCHEMA | | Object with nameservers array | +| POST | /network/dns | STATE | @state | Saves DNS config | +| GET | /network/gateway | SCHEMA | | Returns object | +| POST | /network/gateway | STATE | @state | Saves gateway config | +| GET | /network/interface | SCHEMA | | Returns array of interface objects | +| GET | /network/interface/:interface | SCHEMA | | Use `lo` as safe interface | +| POST | /network/interface/add/:interface | STATE | @state | Adds network interface | +| POST | /network/interface/:interface | STATE | @state | Sets interface config | +| POST | /network/interface/:interface/apply | DESTRUCTIVE | @destructive | Applies network config live | +| DELETE | /network/presisentNames | STATE | @state | Legacy typo alias; DELETE persistent names | +| POST | /network/presisentNames | STATE | @state | Legacy typo alias; POST persistent names | +| DELETE | /network/persistentNames | STATE | @state | Deletes persistent interface names | +| POST | /network/persistentNames | STATE | @state | Creates persistent interface names | +| GET | /network/wifi/scan/:interface | HARDWARE | @hardware:wifi | Requires WiFi interface | +| GET | /network/wifi/strength | HARDWARE | @hardware:wifi | Requires WiFi interface | +| GET | /options/:SettingName | SCHEMA | | Use `fppMode`; returns array of option objects | +| GET | /audio/cardaliases | HARDWARE | @hardware:audio | Requires audio device | +| POST | /audio/cardaliases | HARDWARE | @hardware:audio | Requires audio device | +| GET | /pipewire/audio/groups | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/groups | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/groups/apply | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/audio/sinks | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/audio/cards | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/audio/sources | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/audio/input-groups | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/input-groups | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/input-groups/apply | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/input-groups/volume | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/input-groups/effects | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/input-groups/eq/update | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/audio/routing | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/routing | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/routing/volume | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/audio/routing/presets | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/audio/routing/presets/names | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/routing/presets | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/routing/presets/load | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/routing/presets/live-apply | HARDWARE | @hardware:pipewire | Requires PipeWire | +| DELETE | /pipewire/audio/routing/presets/:name | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/stream/volume | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/audio/stream/status | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/group/volume | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/eq/update | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/delay/update | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/sync/start | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/audio/sync/stop | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/video/groups | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/video/groups | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/video/groups/apply | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/simple/apply | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/video/connectors | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/video/routing | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/video/routing | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/video/input-sources | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/video/input-sources | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/video/input-sources/apply | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/video/input-sources/v4l2-devices | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/aes67/instances | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/aes67/instances | HARDWARE | @hardware:pipewire | Requires PipeWire | +| POST | /pipewire/aes67/apply | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/aes67/status | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/aes67/interfaces | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /pipewire/graph | HARDWARE | @hardware:pipewire | Requires PipeWire | +| GET | /playlists | SCHEMA | | Returns array | +| POST | /playlists | STATE | @state | Create ci-test-playlist; cleanup: delete it | +| GET | /playlists/playable | SCHEMA | | Returns array | +| GET | /playlists/validate | SCHEMA | | Returns object or array | +| POST | /playlists/stop | STATE | @state | Requires running playlist | +| POST | /playlists/pause | STATE | @state | Requires running playlist | +| POST | /playlists/resume | STATE | @state | Requires paused playlist | +| POST | /playlists/stopgracefully | STATE | @state | Requires running playlist | +| POST | /playlists/stopgracefullyafterloop | STATE | @state | Requires running playlist | +| GET | /playlist/:PlaylistName | STATE | @state | Requires playlist to exist | +| POST | /playlist/:PlaylistName | STATE | @state | Upsert; self-contained | +| DELETE | /playlist/:PlaylistName | STATE | @state | Self-contained with create first | +| POST | /playlist/:PlaylistName/start | STATE | @state | Requires playlist to exist | +| POST | /playlist/:PlaylistName/start/:Repeat | STATE | @state | Requires playlist to exist | +| POST | /playlist/:PlaylistName/start/:Repeat/:ScheduleProtected | STATE | @state | Requires playlist to exist | +| POST | /playlist/:PlaylistName/:SectionName/item | STATE | @state | Requires playlist to exist | +| GET | /plugin/headerIndicators | SCHEMA | | Returns array | +| GET | /plugin | SCHEMA | | Returns array | +| POST | /plugin | STATE | @state | Installs plugin from URL | +| POST | /plugin/fetchInfo | STATE | @state | Fetches plugin metadata from URL | +| GET | /plugin/:RepoName | STATE | @state | Requires plugin installed | +| DELETE | /plugin/:RepoName | STATE | @state | Self-contained with install first | +| GET | /plugin/:RepoName/settings/:SettingName | STATE | @state | Requires plugin installed | +| PUT | /plugin/:RepoName/settings/:SettingName | STATE | @state | Requires plugin installed | +| POST | /plugin/:RepoName/settings/:SettingName | STATE | @state | Requires plugin installed | +| POST | /plugin/:RepoName/updates | STATE | @state | Requires plugin installed | +| POST | /plugin/:RepoName/upgrade | STATE | @state | Requires plugin installed | +| GET | / | SCHEMA | | Serves the API docs index | +| GET | /api.html | SCHEMA | | Serves the API HTML UI | +| GET | /openapi.yaml | SCHEMA | | Serves the OpenAPI YAML spec | +| GET | /openapi.json | SCHEMA | | Serves the OpenAPI JSON spec | +| GET | /proxies | FULL | | Returns `[]` on fresh instance | +| POST | /proxies | STATE | @state | Sets full proxy list; self-contained | +| DELETE | /proxies | STATE | @state | Deletes all; self-contained with setup first | +| POST | /proxies/:ProxyIp | STATE | @state | Add 192.0.2.1; cleanup DELETE it | +| DELETE | /proxies/:ProxyIp | STATE | @state | Self-contained with add first | +| GET | /proxy/*/** | STATE | @state | Proxies a request to a remote FPP instance; requires reachable remote at a known IP | +| GET | /remotes | SCHEMA | | Returns array | +| POST | /remoteAction | STATE | @state | Requires a reachable remote FPP instance | +| GET | /schedule | SCHEMA | | Object with scheduleEntries array | +| POST | /schedule | STATE | @state | Overwrites entire schedule | +| POST | /schedule/reload | STATE | @state | Reloads schedule from disk | +| GET | /scripts | SCHEMA | | Returns array | +| POST | /scripts/installRemote/:category/:filename | STATE | @state | Requires internet and valid remote script | +| GET | /scripts/viewRemote/:category/:filename | STATE | @state | Requires internet | +| GET | /scripts/:scriptName | STATE | @state | Requires script to exist | +| POST | /scripts/:scriptName | STATE | @state | Saves script | +| POST | /scripts/:scriptName/run | STATE | @state | Requires script to exist | +| GET | /sequence | SCHEMA | | Returns array | +| POST | /sequence/current/step | STATE | @state | Requires paused sequence | +| POST | /sequence/current/stop | STATE | @state | Requires running sequence | +| POST | /sequence/current/togglePause | STATE | @state | Requires running sequence | +| GET | /sequence/:SequenceName | STATE | @state | Download; requires file | +| GET | /sequence/:SequenceName/meta | STATE | @state | Requires file | +| POST | /sequence/:SequenceName/start/:startSecond | STATE | @state | Requires file | +| POST | /sequence/:SequenceName | STATE | @state | Upload | +| DELETE | /sequence/:SequenceName | STATE | @state | Self-contained with upload first | +| GET | /settings | SCHEMA | | Large settings object | +| GET | /settings/:SettingName | SCHEMA | | Use `fppMode`; string value | +| GET | /settings/:SettingName/options | SCHEMA | | Use `fppMode`; array | +| PUT | /settings/:SettingName | STATE | @state | Change a safe setting; restore in cleanup | +| PUT | /settings/:SettingName/jsonValueUpdate | STATE | @state | JSON value update | +| GET | /statistics/usage | SCHEMA | | Returns object or null | +| POST | /statistics/usage | STATE | @state | Publishes stats | +| DELETE | /statistics/usage | STATE | @state | Deletes stats file | +| GET | /system/info | SCHEMA | | Object with Version, Hostname, etc. | +| GET | /system/status | SCHEMA | | Object with status string | +| GET | /system/updateStatus | SCHEMA | | Returns object | +| GET | /system/releaseNotes/:version | SCHEMA | | Use `current` as version param | +| GET | /system/packages | SCHEMA | | Returns array | +| GET | /system/packages/info/:packageName | SCHEMA | | Use `fpp` as packageName | +| POST | /system/fppd/skipBootDelay | STATE | @state | Skips boot delay | +| GET | /system/volume | HARDWARE | @hardware:audio | Requires audio device | +| POST | /system/volume | HARDWARE | @hardware:audio | Requires audio device | +| POST | /system/fppd/restart | DESTRUCTIVE | @destructive | Kills and restarts fppd | +| POST | /system/fppd/start | DESTRUCTIVE | @destructive | Starts fppd (may fail if already running) | +| POST | /system/fppd/stop | DESTRUCTIVE | @destructive | Kills fppd | +| POST | /system/reboot | DESTRUCTIVE | @destructive | Reboots the OS | +| POST | /system/shutdown | DESTRUCTIVE | @destructive | Shuts down the OS | +| GET | /system/proxies | SCHEMA | | Alias of /proxies | +| POST | /system/proxies | STATE | @state | Alias of POST /proxies | +| GET | /testmode | FULL | | Returns `{ enabled: false }` on fresh instance | +| POST | /testmode | STATE | @state | Enable test mode; cleanup: disable it | +| GET | /time | SCHEMA | | Object with utcDate, localDate, utcOffset | + +## Known Gaps (in openapi.json but not in router) + +| Verb | Endpoint | Notes | +| --- | --- | --- | +| n/a | /proxy/:Ip/:urlPart | Human-readable alias for GET /proxy/*/** which is dispatched via PHP array form; covered in Routes table | diff --git a/tests/playwright/fixtures/playback.ts b/tests/playwright/fixtures/playback.ts new file mode 100644 index 000000000..8818c55fd --- /dev/null +++ b/tests/playwright/fixtures/playback.ts @@ -0,0 +1,25 @@ +import { test as base, expect } from '@playwright/test'; + +export const PLAYLIST_NAME = 'ci-test-playlist'; +export const V2 = '/api/v2'; + +export async function createTestPlaylist(request: import('@playwright/test').APIRequestContext) { + await request.post(`${V2}/playlist/${PLAYLIST_NAME}`, { + data: { + name: PLAYLIST_NAME, + mainPlaylist: [{ type: 'pause', duration: 60 }], + playlistInfo: { loop: false, description: 'CI test playlist' }, + }, + }); +} + +export async function startTestPlaylist(request: import('@playwright/test').APIRequestContext) { + await request.post(`${V2}/playlist/${PLAYLIST_NAME}/start/0`); +} + +export async function deleteTestPlaylist(request: import('@playwright/test').APIRequestContext) { + await request.post(`${V2}/playlists/stop`); + await request.delete(`${V2}/playlist/${PLAYLIST_NAME}`); +} + +export { test as base, expect } from '@playwright/test'; diff --git a/tests/playwright/fixtures/version.ts b/tests/playwright/fixtures/version.ts new file mode 100644 index 000000000..767c387cc --- /dev/null +++ b/tests/playwright/fixtures/version.ts @@ -0,0 +1,40 @@ +import { APIRequestContext, APIResponse, test } from '@playwright/test'; + +export const V1 = '/api'; +export const V2 = '/api/v2'; + +type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +type VersionRoute = { + method: Method; + path: string; + tags: string[]; +}; + +type ResponseAssert = (response: APIResponse) => Promise; + +async function dispatchRequest( + request: APIRequestContext, + route: VersionRoute, + basePath: string +): Promise { + const method = route.method.toLowerCase() as Lowercase; + return request[method](`${basePath}${route.path}`); +} + +export function testBothVerbVersions( + label: string, + v1Route: VersionRoute, + v2Route: VersionRoute, + assertResponse: ResponseAssert +): void { + test(`${label} [v1]`, { tag: v1Route.tags }, async ({ request }) => { + const response = await dispatchRequest(request, v1Route, V1); + await assertResponse(response); + }); + + test(`${label} [v2]`, { tag: v2Route.tags }, async ({ request }) => { + const response = await dispatchRequest(request, v2Route, V2); + await assertResponse(response); + }); +} diff --git a/tests/playwright/package.json b/tests/playwright/package.json index fc78954eb..53948f019 100644 --- a/tests/playwright/package.json +++ b/tests/playwright/package.json @@ -7,7 +7,19 @@ "test:headed": "playwright test --headed", "test:ui": "playwright test --ui", "report": "playwright show-report", - "install:browsers": "playwright install" + "install:browsers": "playwright install", + + "test:api:ci": "playwright test --project=api-ci --grep-invert \"@(state|hardware|destructive)\"", + "test:api:v1": "playwright test --project=api-ci --grep @v1", + "test:api:v2": "playwright test --project=api-ci --grep @v2", + "test:api:state": "playwright test --project=api-state --grep @state", + "test:api:hardware:cape": "PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL} playwright test --project=api-hardware-cape --grep \"@hardware:cape\"", + "test:api:hardware:wifi": "PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL} playwright test --project=api-hardware-wifi --grep \"@hardware:wifi\"", + "test:api:hardware:audio": "PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL} playwright test --project=api-hardware-audio --grep \"@hardware:audio\"", + "test:api:hardware:pipewire": "PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL} playwright test --project=api-hardware-pipewire --grep \"@hardware:pipewire\"", + "test:api:destructive": "PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL} playwright test --project=api-destructive --grep @destructive", + + "check:api-coverage": "npx ts-node --project tsconfig.json scripts/check-api-coverage.ts" }, "devDependencies": { "@playwright/test": "^1.54.2", diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 68fed7493..6b347fe40 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -20,19 +20,64 @@ export default defineConfig({ viewport: { width: 1920, height: 1080 }, }, projects: [ + // ── UI projects ────────────────────────────────────────────────────────── { name: 'chromium-light', - use: { - ...devices['Desktop Chrome'], - colorScheme: 'light', - }, + testMatch: ['**/fpp.spec.ts', '**/theme-override.spec.ts'], + use: { ...devices['Desktop Chrome'], colorScheme: 'light' }, }, { name: 'chromium-dark', - use: { - ...devices['Desktop Chrome'], - colorScheme: 'dark', - }, + testMatch: ['**/fpp.spec.ts', '**/theme-override.spec.ts'], + use: { ...devices['Desktop Chrome'], colorScheme: 'dark' }, + }, + + // ── API v2 projects ─────────────────────────────────────────────────────── + // CI: FULL + SCHEMA only — no @state, @hardware:*, or @destructive tags + // Run via: npm run test:api:ci + { + name: 'api-ci', + testMatch: '**/api-v2/**/*.spec.ts', + use: { baseURL }, + }, + + // Stateful tests — require a writable FPP instance; manage their own setup/teardown + // Run via: npm run test:api:state + { + name: 'api-state', + testMatch: '**/api-v2/**/*.spec.ts', + use: { baseURL }, + }, + + // Hardware-gated projects — one per capability; point at real hardware via PLAYWRIGHT_BASE_URL + // Run via: npm run test:api:hardware:cape (or :wifi, :audio, :pipewire) + { + name: 'api-hardware-cape', + testMatch: '**/api-v2/**/*.spec.ts', + use: { baseURL }, + }, + { + name: 'api-hardware-wifi', + testMatch: '**/api-v2/**/*.spec.ts', + use: { baseURL }, + }, + { + name: 'api-hardware-audio', + testMatch: '**/api-v2/**/*.spec.ts', + use: { baseURL }, + }, + { + name: 'api-hardware-pipewire', + testMatch: '**/api-v2/**/*.spec.ts', + use: { baseURL }, + }, + + // Destructive — kills fppd / reboots / shuts down; run only on sacrificial hardware + // Run via: npm run test:api:destructive + { + name: 'api-destructive', + testMatch: '**/api-v2/**/*.spec.ts', + use: { baseURL }, }, ], outputDir: 'test-results', diff --git a/tests/playwright/scripts/check-api-coverage.ts b/tests/playwright/scripts/check-api-coverage.ts new file mode 100644 index 000000000..b80dbc07f --- /dev/null +++ b/tests/playwright/scripts/check-api-coverage.ts @@ -0,0 +1,148 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const repoRoot = path.resolve(__dirname, '../../..'); + +function readRouterPaths(): Array<{ method: string; path: string }> { + const indexPhp = fs.readFileSync(path.join(repoRoot, 'www/api/index.php'), 'utf8'); + const routes: Array<{ method: string; path: string }> = []; + + // Shared dispatches: dispatch_all('/some/path', 'get', ...) + const shared = /dispatch_all\s*\(\s*'([^']+)'\s*,\s*'(get|post|put|delete|patch)'/gi; + let m: RegExpExecArray | null; + while ((m = shared.exec(indexPhp)) !== null) { + routes.push({ method: m[2].toUpperCase(), path: `/v2${m[1]}`.toLowerCase() }); + } + + // Explicit v2 routes: dispatch_get('/v2/some/path', ...) + const simple = /dispatch_(get|post|put|delete|patch)\s*\(\s*'([^']+)'/gi; + while ((m = simple.exec(indexPhp)) !== null) { + const routePath = m[2].toLowerCase(); + if (routePath.startsWith('/v2/')) { + routes.push({ method: m[1].toUpperCase(), path: routePath }); + } + } + + // Array-form literal paths: dispatch_get(array('/v2/proxy/*/**', array(...)), ...) + const arrayForm = /dispatch_(get|post|put|delete|patch)\s*\(\s*array\s*\(\s*'([^']+)'/gi; + while ((m = arrayForm.exec(indexPhp)) !== null) { + const routePath = m[2].toLowerCase(); + if (routePath.startsWith('/v2/')) { + routes.push({ method: m[1].toUpperCase(), path: routePath }); + } + } + + if (indexPhp.includes("dispatch_get(array($prefix . '/proxy/*/**'")) { + routes.push({ method: 'GET', path: '/v2/proxy/*/**' }); + } + + return routes.map(r => ({ method: r.method, path: normalizeV2Path(r.path) })); +} + +function readOpenApiPaths(): Array<{ method: string; path: string }> { + const raw = fs.readFileSync(path.join(repoRoot, 'www/api/v2/openapi.json'), 'utf8'); + const spec = JSON.parse(raw); + const routes: Array<{ method: string; path: string }> = []; + for (const [rawPath, methods] of Object.entries(spec.paths || {})) { + const normalized = normalizeSpecPath(rawPath.toLowerCase()); + for (const method of Object.keys(methods as object)) { + if (['get', 'post', 'put', 'delete', 'patch'].includes(method.toLowerCase())) { + routes.push({ method: method.toUpperCase(), path: normalized }); + } + } + } + return routes; +} + +function readCoveragePaths(): Array<{ method: string; path: string }> { + const md = fs.readFileSync(path.join(repoRoot, 'tests/playwright/API_COVERAGE.md'), 'utf8'); + const routes: Array<{ method: string; path: string }> = []; + // Match table rows: | METHOD | /path | ... + const re = /^\|\s*(GET|POST|PUT|DELETE|PATCH)\s*\|\s*([^\s|]+)\s*\|/gim; + let m: RegExpExecArray | null; + while ((m = re.exec(md)) !== null) { + routes.push({ method: m[1].toUpperCase(), path: normalizeComparablePath(m[2].toLowerCase()) }); + } + return routes; +} + +function key(method: string, p: string): string { + return `${method.toUpperCase()} ${normalizeComparablePath(p.toLowerCase())}`; +} + +function normalizeV2Path(p: string): string { + if (p === '/v2') return '/'; + if (p.startsWith('/v2/')) return normalizeComparablePath(p.slice(3) || '/'); + return normalizeComparablePath(p); +} + +function normalizeSpecPath(p: string): string { + if (p === '/api/v2') return '/'; + if (p.startsWith('/api/v2/')) return normalizeComparablePath(p.slice('/api/v2'.length) || '/'); + return normalizeComparablePath(p); +} + +function normalizeComparablePath(p: string): string { + return p + .replace(/\{[^}]+\}/g, '{}') + .replace(/:[^/]+/g, '{}') + .replace(/\/\*\*/g, '/{}') + .replace(/\/\*/g, '/{}') + .replace(/\/+$/g, '') || '/'; +} + +function main() { + const routerRoutes = readRouterPaths(); + const openApiRoutes = readOpenApiPaths(); + const coverageRoutes = readCoveragePaths(); + + const routerKeys = new Set(routerRoutes.map(r => key(r.method, r.path))); + const coverageKeys = new Set(coverageRoutes.map(r => key(r.method, r.path))); + const openApiKeys = new Set(openApiRoutes.map(r => key(r.method, r.path))); + + const missing = routerRoutes.filter(r => !coverageKeys.has(key(r.method, r.path))); + const stale = coverageRoutes.filter(r => !routerKeys.has(key(r.method, r.path))); + const specOnly = openApiRoutes.filter(r => !routerKeys.has(key(r.method, r.path))); + + console.log('\n=== API Coverage Check ===\n'); + + if (missing.length > 0) { + console.log(`MISSING from API_COVERAGE.md (${missing.length}):`); + for (const r of missing) { + console.log(` ${r.method} ${r.path}`); + } + console.log(); + } else { + console.log('MISSING from API_COVERAGE.md: none\n'); + } + + if (stale.length > 0) { + console.log(`Stale in API_COVERAGE.md — not in router (${stale.length}):`); + for (const r of stale) { + console.log(` ${r.method} ${r.path}`); + } + console.log(); + } else { + console.log('Stale in API_COVERAGE.md: none\n'); + } + + if (specOnly.length > 0) { + console.log(`In openapi.json but NOT in router (${specOnly.length}):`); + for (const r of specOnly) { + console.log(` ${r.method} ${r.path}`); + } + console.log(); + } else { + console.log('In openapi.json but NOT in router: none\n'); + } + + console.log( + `Summary: ${routerRoutes.length} router routes, ${coverageRoutes.length} coverage entries, ${openApiRoutes.length} openapi entries` + ); + + if (missing.length > 0) { + process.exit(1); + } +} + +main(); diff --git a/tests/playwright/tests/api-v2/audio.spec.ts b/tests/playwright/tests/api-v2/audio.spec.ts new file mode 100644 index 000000000..3c39af2a8 --- /dev/null +++ b/tests/playwright/tests/api-v2/audio.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('audio', () => { + + test('GET /audio/cardaliases', { tag: ['@hardware:audio'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/audio/cardaliases`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /audio/cardaliases', { tag: ['@hardware:audio'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/audio/cardaliases`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/backups.spec.ts b/tests/playwright/tests/api-v2/backups.spec.ts new file mode 100644 index 000000000..4b4ae688d --- /dev/null +++ b/tests/playwright/tests/api-v2/backups.spec.ts @@ -0,0 +1,91 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('backups', () => { + + test('GET /backups/list', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/backups/list`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /backups/list/:DeviceName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: external device mounted with a known DeviceName + test.skip(true, 'Requires mounted external backup device'); + }); + + test('GET /backups/devices', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/backups/devices`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('POST /backups/devices/mount/:DeviceName/:MountLocation', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a known removable device path on the system + test.skip(true, 'Requires a physically attached backup device'); + }); + + test('POST /backups/devices/unmount/:DeviceName/:MountLocation', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a mounted device that can be safely unmounted + test.skip(true, 'Requires a mounted backup device'); + }); + + test('POST /backups/configuration — create then delete', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const create = await request.post(`${V2}/backups/configuration`); + expect(create.status()).toBe(200); + const body = await create.json(); + expect(body).toHaveProperty('filename'); + expect(typeof body.filename).toBe('string'); + + const filename: string = body.filename; + // Backups are stored under a directory — split on the last '/' to get dir and file + const lastSlash = filename.lastIndexOf('/'); + const dir = lastSlash >= 0 ? filename.substring(0, lastSlash) : '.'; + const file = lastSlash >= 0 ? filename.substring(lastSlash + 1) : filename; + + const del = await request.delete(`${V2}/backups/configuration/${dir}/${file}`); + expect(del.status()).toBe(200); + }); + + test('GET /backups/configuration/list', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/backups/configuration/list`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /backups/configuration/list/:DeviceName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: external device mounted at a known DeviceName + test.skip(true, 'Requires mounted external backup device'); + }); + + test('POST /backups/configuration/restore/:Directory/:BackupFilename', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a backup to exist at a known directory/filename + test.skip(true, 'Requires a pre-existing backup file'); + }); + + test('GET /backups/configuration/:Directory/:BackupFilename', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a backup to exist at a known directory/filename + test.skip(true, 'Requires a pre-existing backup file'); + }); + + test('DELETE /backups/configuration/:Directory/:BackupFilename', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Covered inline by the POST /backups/configuration STATE test above + test.skip(true, 'Covered inline by POST /backups/configuration test'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/cape.spec.ts b/tests/playwright/tests/api-v2/cape.spec.ts new file mode 100644 index 000000000..4e7ffe432 --- /dev/null +++ b/tests/playwright/tests/api-v2/cape.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('cape', () => { + + test('GET /cape', { tag: ['@hardware:cape'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/cape`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /cape/eeprom/voucher', { tag: ['@hardware:cape'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/cape/eeprom/voucher`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /cape/eeprom/sign/:key/:order', { tag: ['@hardware:cape'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + // Requires: valid key and order values from a real cape provisioning workflow + test.skip(true, 'Requires valid cape signing key and order'); + }); + + test('GET /cape/eeprom/signingData/:key/:order', { tag: ['@hardware:cape'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + // Requires: valid key and order values from a real cape provisioning workflow + test.skip(true, 'Requires valid cape signing key and order'); + }); + + test('GET /cape/eeprom/signingFile/:key/:order', { tag: ['@hardware:cape'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + // Requires: valid key and order values from a real cape provisioning workflow + test.skip(true, 'Requires valid cape signing key and order'); + }); + + test('POST /cape/eeprom/signingData', { tag: ['@hardware:cape'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/cape/eeprom/signingData`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /cape/options', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/cape/options`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /cape/strings', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/cape/strings`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /cape/panel', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/cape/panel`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /cape/strings/:key', { tag: ['@hardware:cape'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + // Requires: cape with string outputs configured; key must be a valid string config key + test.skip(true, 'Requires physical cape with string outputs'); + }); + + test('GET /cape/panel/:key', { tag: ['@hardware:cape'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + // Requires: cape with panel outputs configured; key must be a valid panel config key + test.skip(true, 'Requires physical cape with panel outputs'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/channel.spec.ts b/tests/playwright/tests/api-v2/channel.spec.ts new file mode 100644 index 000000000..fd2da1a76 --- /dev/null +++ b/tests/playwright/tests/api-v2/channel.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('channel', () => { + + test('GET /channel/input/stats', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/channel/input/stats`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('DELETE /channel/input/stats — resets stats', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const res = await request.delete(`${V2}/channel/input/stats`); + expect(res.status()).toBe(200); + }); + + test('GET /channel/output/processors', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/channel/output/processors`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('POST /channel/output/processors', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Read current processors first so we can restore them + const getRes = await request.get(`${V2}/channel/output/processors`); + expect(getRes.status()).toBe(200); + const original = await getRes.json(); + + const saveRes = await request.post(`${V2}/channel/output/processors`, { data: original }); + expect(saveRes.status()).toBe(200); + }); + + test('GET /channel/output/:file', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/channel/output/channeloutputs`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /channel/output/:file', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Read current config and write it back unchanged + const getRes = await request.get(`${V2}/channel/output/channeloutputs`); + expect(getRes.status()).toBe(200); + const original = await getRes.json(); + + const saveRes = await request.post(`${V2}/channel/output/channeloutputs`, { data: original }); + expect(saveRes.status()).toBe(200); + }); + +}); diff --git a/tests/playwright/tests/api-v2/compat.spec.ts b/tests/playwright/tests/api-v2/compat.spec.ts new file mode 100644 index 000000000..da5545d98 --- /dev/null +++ b/tests/playwright/tests/api-v2/compat.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; +import { testBothVerbVersions, V1, V2 } from '../../fixtures/version'; + +test.describe('api compatibility', () => { + testBothVerbVersions( + '/system/reboot', + { method: 'GET', path: '/system/reboot', tags: ['@v1', '@destructive'] }, + { method: 'POST', path: '/system/reboot', tags: ['@v2', '@destructive'] }, + async (res) => { + expect([200, 202]).toContain(res.status()); + } + ); + + test('GET /remoteAction [v1]', { tag: ['@v1', '@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + test.skip(true, 'Requires a reachable remote FPP instance at a known IP'); + await request.get(`${V1}/remoteAction?ip=192.0.2.1&action=reboot`); + }); + + test('POST /remoteAction [v2]', { tag: ['@v2', '@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + test.skip(true, 'Requires a reachable remote FPP instance at a known IP'); + await request.post(`${V2}/remoteAction`, { + data: { ip: '192.0.2.1', action: 'reboot' }, + }); + }); +}); diff --git a/tests/playwright/tests/api-v2/configfile.spec.ts b/tests/playwright/tests/api-v2/configfile.spec.ts new file mode 100644 index 000000000..0e8c32074 --- /dev/null +++ b/tests/playwright/tests/api-v2/configfile.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('configfile', () => { + + test('GET /configfile', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/configfile`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /configfile/** — download known config', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/configfile/settings`); + expect(res.status()).toBe(200); + }); + + test('POST /configfile/** then DELETE — upload then remove', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const content = '# CI test config file\n'; + const uploadRes = await request.post(`${V2}/configfile/ci-test-config.txt`, { + headers: { 'Content-Type': 'text/plain' }, + data: content, + }); + expect(uploadRes.status()).toBe(200); + + const delRes = await request.delete(`${V2}/configfile/ci-test-config.txt`); + expect(delRes.status()).toBe(200); + }); + +}); diff --git a/tests/playwright/tests/api-v2/dir.spec.ts b/tests/playwright/tests/api-v2/dir.spec.ts new file mode 100644 index 000000000..22492574c --- /dev/null +++ b/tests/playwright/tests/api-v2/dir.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('dir', () => { + + test('POST /dir/:DirName/:SubDir then DELETE — create then remove', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const createRes = await request.post(`${V2}/dir/sequences/ci-test-dir`); + expect(createRes.status()).toBe(200); + + const delRes = await request.delete(`${V2}/dir/sequences/ci-test-dir`); + expect(delRes.status()).toBe(200); + }); + +}); diff --git a/tests/playwright/tests/api-v2/effects.spec.ts b/tests/playwright/tests/api-v2/effects.spec.ts new file mode 100644 index 000000000..ca0e6d52d --- /dev/null +++ b/tests/playwright/tests/api-v2/effects.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('effects', () => { + + test('GET /effects', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/effects`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /effects/ALL', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/effects/ALL`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + +}); diff --git a/tests/playwright/tests/api-v2/email.spec.ts b/tests/playwright/tests/api-v2/email.spec.ts new file mode 100644 index 000000000..f2347b15a --- /dev/null +++ b/tests/playwright/tests/api-v2/email.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('email', () => { + + test('POST /email/configure', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: valid SMTP config values; this will mutate the email configuration on the device + test.skip(true, 'Requires valid SMTP credentials and mail server config'); + }); + + test('POST /email/test', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // This sends an actual email to the configured recipient; only run with a real mail config + test.skip(true, 'Sends a real email — requires configured email settings on the device'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/events.spec.ts b/tests/playwright/tests/api-v2/events.spec.ts new file mode 100644 index 000000000..4f5610772 --- /dev/null +++ b/tests/playwright/tests/api-v2/events.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('events', () => { + + test('GET /events', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/events`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /events/:eventId', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: an event file to exist on the device with a known eventId + test.skip(true, 'Requires a pre-existing event file on the device'); + }); + + test('POST /events/:eventId/trigger', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: an event file to exist on the device with a known eventId + test.skip(true, 'Requires a pre-existing event file on the device'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/file.spec.ts b/tests/playwright/tests/api-v2/file.spec.ts new file mode 100644 index 000000000..a023ac590 --- /dev/null +++ b/tests/playwright/tests/api-v2/file.spec.ts @@ -0,0 +1,141 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('file', () => { + + test('GET /file/info/:plugin/:ext/**', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: an installed plugin with a known file + test.skip(true, 'Requires an installed plugin with a known file'); + }); + + test('POST /file/onUpload/:ext/**', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: an installed plugin with an onUpload handler for the given extension + test.skip(true, 'Requires an installed plugin with a file upload handler'); + }); + + test('POST /file/move/:fileName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a file to exist at a known path; this is tested inline via POST /file/:DirName + test.skip(true, 'Requires a pre-existing file to move'); + }); + + test('POST /file/:DirName/copy/:source/:dest', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Upload a source file, copy it, then clean up both + const uploadRes = await request.post(`${V2}/file/sequences`, { + multipart: { + file: { + name: 'ci-copy-source.txt', + mimeType: 'text/plain', + buffer: Buffer.from('ci test content'), + }, + }, + }); + expect(uploadRes.status()).toBe(200); + + const copyRes = await request.post(`${V2}/file/sequences/copy/ci-copy-source.txt/ci-copy-dest.txt`); + expect(copyRes.status()).toBe(200); + + await request.delete(`${V2}/file/sequences/ci-copy-source.txt`); + await request.delete(`${V2}/file/sequences/ci-copy-dest.txt`); + }); + + test('POST /file/:DirName/rename/:source/:dest', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const uploadRes = await request.post(`${V2}/file/sequences`, { + multipart: { + file: { + name: 'ci-rename-source.txt', + mimeType: 'text/plain', + buffer: Buffer.from('ci test content'), + }, + }, + }); + expect(uploadRes.status()).toBe(200); + + const renameRes = await request.post(`${V2}/file/sequences/rename/ci-rename-source.txt/ci-rename-dest.txt`); + expect(renameRes.status()).toBe(200); + + await request.delete(`${V2}/file/sequences/ci-rename-dest.txt`); + }); + + test('GET /file/:DirName/tailfollow/**', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Streaming (chunked transfer) endpoint — meaningful only when consumed as a stream + test.skip(true, 'Streaming endpoint requires SSE/chunked consumer; not suitable for request fixture'); + }); + + test('GET /file/:DirName/**', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Upload a file first, then download it + const content = 'ci get test\n'; + const uploadRes = await request.post(`${V2}/file/sequences`, { + multipart: { + file: { + name: 'ci-get-test.txt', + mimeType: 'text/plain', + buffer: Buffer.from(content), + }, + }, + }); + expect(uploadRes.status()).toBe(200); + + const getRes = await request.get(`${V2}/file/sequences/ci-get-test.txt`); + expect(getRes.status()).toBe(200); + + await request.delete(`${V2}/file/sequences/ci-get-test.txt`); + }); + + test('DELETE /file/:DirName/**', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const uploadRes = await request.post(`${V2}/file/sequences`, { + multipart: { + file: { + name: 'ci-delete-test.txt', + mimeType: 'text/plain', + buffer: Buffer.from('ci delete test\n'), + }, + }, + }); + expect(uploadRes.status()).toBe(200); + + const delRes = await request.delete(`${V2}/file/sequences/ci-delete-test.txt`); + expect(delRes.status()).toBe(200); + }); + + test('POST /file/:DirName — upload', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const uploadRes = await request.post(`${V2}/file/sequences`, { + multipart: { + file: { + name: 'ci-upload-test.txt', + mimeType: 'text/plain', + buffer: Buffer.from('ci upload test\n'), + }, + }, + }); + expect(uploadRes.status()).toBe(200); + + await request.delete(`${V2}/file/sequences/ci-upload-test.txt`); + }); + + test('POST /file/:DirName/:Name — upload with explicit name', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const uploadRes = await request.post(`${V2}/file/sequences/ci-named-upload.txt`, { + multipart: { + file: { + name: 'ci-named-upload.txt', + mimeType: 'text/plain', + buffer: Buffer.from('ci named upload test\n'), + }, + }, + }); + expect(uploadRes.status()).toBe(200); + + await request.delete(`${V2}/file/sequences/ci-named-upload.txt`); + }); + +}); diff --git a/tests/playwright/tests/api-v2/files.spec.ts b/tests/playwright/tests/api-v2/files.spec.ts new file mode 100644 index 000000000..66f37e56f --- /dev/null +++ b/tests/playwright/tests/api-v2/files.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('files', () => { + + test('GET /files/:DirName', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/files/sequences`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /files/zip/:DirNames', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/files/zip/sequences`); + expect(res.status()).toBe(200); + const contentType = res.headers()['content-type'] || ''; + expect(contentType).toMatch(/zip|octet-stream/i); + }); + +}); diff --git a/tests/playwright/tests/api-v2/git.spec.ts b/tests/playwright/tests/api-v2/git.spec.ts new file mode 100644 index 000000000..afa4ec02a --- /dev/null +++ b/tests/playwright/tests/api-v2/git.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('git', () => { + + test('GET /git/originLog', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/git/originLog`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /git/releases/os/:All', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/git/releases/os/0`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /git/releases/sizes', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/git/releases/sizes`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /git/status', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/git/status`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /git/branches', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/git/branches`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('POST /git/reset', { tag: ['@destructive'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'DESTRUCTIVE' }); + // Resets git repository state — discards local modifications + const res = await request.post(`${V2}/git/reset`); + expect([200, 202]).toContain(res.status()); + }); + +}); diff --git a/tests/playwright/tests/api-v2/media.spec.ts b/tests/playwright/tests/api-v2/media.spec.ts new file mode 100644 index 000000000..d56674778 --- /dev/null +++ b/tests/playwright/tests/api-v2/media.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('media', () => { + + test('GET /media', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/media`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /media/:MediaName/duration', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a media file to exist on the device with a known name + test.skip(true, 'Requires a pre-existing media file on the device'); + }); + + test('GET /media/:MediaName/meta', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a media file to exist on the device with a known name + test.skip(true, 'Requires a pre-existing media file on the device'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/network.spec.ts b/tests/playwright/tests/api-v2/network.spec.ts new file mode 100644 index 000000000..82ec5a4a2 --- /dev/null +++ b/tests/playwright/tests/api-v2/network.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('network', () => { + + test('GET /network/dns', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/network/dns`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /network/dns', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Read existing DNS config and write it back unchanged to avoid disruption + const getRes = await request.get(`${V2}/network/dns`); + expect(getRes.status()).toBe(200); + const original = await getRes.json(); + + const saveRes = await request.post(`${V2}/network/dns`, { data: original }); + expect(saveRes.status()).toBe(200); + }); + + test('GET /network/gateway', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/network/gateway`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /network/gateway', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Read existing gateway config and write it back unchanged + const getRes = await request.get(`${V2}/network/gateway`); + expect(getRes.status()).toBe(200); + const original = await getRes.json(); + + const saveRes = await request.post(`${V2}/network/gateway`, { data: original }); + expect(saveRes.status()).toBe(200); + }); + + test('GET /network/interface', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/network/interface`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /network/interface/:interface', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + // `lo` (loopback) is guaranteed to exist on all Linux and macOS systems + const res = await request.get(`${V2}/network/interface/lo`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /network/interface/add/:interface', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: valid interface name and config; skip to avoid disrupting network + test.skip(true, 'Requires valid interface name — risk of disrupting network config'); + }); + + test('POST /network/interface/:interface', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Read existing lo config and write it back unchanged + const getRes = await request.get(`${V2}/network/interface/lo`); + expect(getRes.status()).toBe(200); + const original = await getRes.json(); + + const saveRes = await request.post(`${V2}/network/interface/lo`, { data: original }); + expect(saveRes.status()).toBe(200); + }); + + test('POST /network/interface/:interface/apply', { tag: ['@destructive'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'DESTRUCTIVE' }); + // Applies network configuration live — may briefly interrupt connectivity + const res = await request.post(`${V2}/network/interface/lo/apply`); + expect([200, 202]).toContain(res.status()); + }); + + test('DELETE /network/persistentNames', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const res = await request.delete(`${V2}/network/persistentNames`); + expect(res.status()).toBe(200); + }); + + test('POST /network/persistentNames', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const res = await request.post(`${V2}/network/persistentNames`); + expect(res.status()).toBe(200); + }); + + test('GET /network/wifi/scan/:interface', { tag: ['@hardware:wifi'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + // Requires: WiFi interface name — common values are wlan0 or wlan1 + test.skip(true, 'Requires WiFi interface name which is device-specific'); + }); + + test('GET /network/wifi/strength', { tag: ['@hardware:wifi'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/network/wifi/strength`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/options.spec.ts b/tests/playwright/tests/api-v2/options.spec.ts new file mode 100644 index 000000000..06c5f57a7 --- /dev/null +++ b/tests/playwright/tests/api-v2/options.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('options', () => { + + test('GET /options/:SettingName', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/options/fppMode`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThan(0); + for (const item of body) { + expect(typeof item).toBe('object'); + } + }); + +}); diff --git a/tests/playwright/tests/api-v2/pipewire.spec.ts b/tests/playwright/tests/api-v2/pipewire.spec.ts new file mode 100644 index 000000000..73f80caf5 --- /dev/null +++ b/tests/playwright/tests/api-v2/pipewire.spec.ts @@ -0,0 +1,409 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('pipewire', () => { + + test('GET /pipewire/audio/groups', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/audio/groups`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/groups', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/groups`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/groups/apply', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/groups/apply`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/audio/sinks', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/audio/sinks`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/audio/cards', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/audio/cards`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/audio/sources', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/audio/sources`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/audio/input-groups', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/audio/input-groups`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/input-groups', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/input-groups`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/input-groups/apply', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/input-groups/apply`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/input-groups/volume', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/input-groups/volume`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/input-groups/effects', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/input-groups/effects`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/input-groups/eq/update', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/input-groups/eq/update`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/audio/routing', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/audio/routing`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/routing', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/routing`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/routing/volume', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/routing/volume`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/audio/routing/presets', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/audio/routing/presets`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/audio/routing/presets/names', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/audio/routing/presets/names`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/routing/presets', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/routing/presets`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/routing/presets/load', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/routing/presets/load`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/routing/presets/live-apply', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/routing/presets/live-apply`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('DELETE /pipewire/audio/routing/presets/:name', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + // Requires: a preset with the given name to exist + test.skip(true, 'Requires a pre-existing named routing preset'); + }); + + test('POST /pipewire/audio/stream/volume', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/stream/volume`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/audio/stream/status', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/audio/stream/status`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/group/volume', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/group/volume`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/eq/update', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/eq/update`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/delay/update', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/delay/update`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/sync/start', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/sync/start`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/audio/sync/stop', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/audio/sync/stop`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/video/groups', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/video/groups`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/video/groups', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/video/groups`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/video/groups/apply', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/video/groups/apply`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/simple/apply', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/simple/apply`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/video/connectors', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/video/connectors`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/video/routing', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/video/routing`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/video/routing', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/video/routing`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/video/input-sources', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/video/input-sources`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/video/input-sources', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/video/input-sources`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/video/input-sources/apply', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/video/input-sources/apply`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/video/input-sources/v4l2-devices', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/video/input-sources/v4l2-devices`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/aes67/instances', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/aes67/instances`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/aes67/instances', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/aes67/instances`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /pipewire/aes67/apply', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/pipewire/aes67/apply`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/aes67/status', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/aes67/status`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/aes67/interfaces', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/aes67/interfaces`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /pipewire/graph', { tag: ['@hardware:pipewire'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/pipewire/graph`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/playlist.spec.ts b/tests/playwright/tests/api-v2/playlist.spec.ts new file mode 100644 index 000000000..cf86cb4c4 --- /dev/null +++ b/tests/playwright/tests/api-v2/playlist.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; +import { createTestPlaylist, deleteTestPlaylist, startTestPlaylist, PLAYLIST_NAME, V2 } from '../../fixtures/playback'; + +test.describe('playlist', () => { + + test('GET /playlist/:PlaylistName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + await createTestPlaylist(request); + + const res = await request.get(`${V2}/playlist/${PLAYLIST_NAME}`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('name', PLAYLIST_NAME); + + await deleteTestPlaylist(request); + }); + + test('POST /playlist/:PlaylistName — upsert self-contained', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const res = await request.post(`${V2}/playlist/${PLAYLIST_NAME}`, { + data: { + name: PLAYLIST_NAME, + mainPlaylist: [{ type: 'pause', duration: 60 }], + playlistInfo: { loop: false, description: 'CI test playlist' }, + }, + }); + expect(res.status()).toBe(200); + + await request.delete(`${V2}/playlist/${PLAYLIST_NAME}`); + }); + + test('DELETE /playlist/:PlaylistName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + await createTestPlaylist(request); + + const res = await request.delete(`${V2}/playlist/${PLAYLIST_NAME}`); + expect(res.status()).toBe(200); + }); + + test('POST /playlist/:PlaylistName/start', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + await createTestPlaylist(request); + + const res = await request.post(`${V2}/playlist/${PLAYLIST_NAME}/start`); + expect(res.status()).toBe(200); + + await deleteTestPlaylist(request); + }); + + test('POST /playlist/:PlaylistName/start/:Repeat', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + await createTestPlaylist(request); + + const res = await request.post(`${V2}/playlist/${PLAYLIST_NAME}/start/0`); + expect(res.status()).toBe(200); + + await deleteTestPlaylist(request); + }); + + test('POST /playlist/:PlaylistName/start/:Repeat/:ScheduleProtected', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + await createTestPlaylist(request); + + const res = await request.post(`${V2}/playlist/${PLAYLIST_NAME}/start/0/0`); + expect(res.status()).toBe(200); + + await deleteTestPlaylist(request); + }); + + test('POST /playlist/:PlaylistName/:SectionName/item', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + await createTestPlaylist(request); + + const res = await request.post(`${V2}/playlist/${PLAYLIST_NAME}/mainPlaylist/item`, { + data: { type: 'pause', duration: 5 }, + }); + expect(res.status()).toBe(200); + + await deleteTestPlaylist(request); + }); + +}); diff --git a/tests/playwright/tests/api-v2/playlists.spec.ts b/tests/playwright/tests/api-v2/playlists.spec.ts new file mode 100644 index 000000000..ddfe59e3b --- /dev/null +++ b/tests/playwright/tests/api-v2/playlists.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from '@playwright/test'; +import { createTestPlaylist, deleteTestPlaylist, startTestPlaylist, PLAYLIST_NAME, V2 } from '../../fixtures/playback'; + +test.describe('playlists', () => { + + test('GET /playlists', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/playlists`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('POST /playlists — insert then clean up', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const res = await request.post(`${V2}/playlists`, { + data: { + name: PLAYLIST_NAME, + mainPlaylist: [{ type: 'pause', duration: 60 }], + playlistInfo: { loop: false, description: 'CI test playlist' }, + }, + }); + expect(res.status()).toBe(200); + await request.delete(`${V2}/playlist/${PLAYLIST_NAME}`); + }); + + test('GET /playlists/playable', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/playlists/playable`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /playlists/validate', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/playlists/validate`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + }); + + test('POST /playlists/stop', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a running playlist; stopping with nothing playing should still return 200 + await createTestPlaylist(request); + await startTestPlaylist(request); + const res = await request.post(`${V2}/playlists/stop`); + expect(res.status()).toBe(200); + await request.delete(`${V2}/playlist/${PLAYLIST_NAME}`); + }); + + test('POST /playlists/pause', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a running playlist to pause + await createTestPlaylist(request); + await startTestPlaylist(request); + const res = await request.post(`${V2}/playlists/pause`); + expect(res.status()).toBe(200); + await deleteTestPlaylist(request); + }); + + test('POST /playlists/resume', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a paused playlist to resume + await createTestPlaylist(request); + await startTestPlaylist(request); + await request.post(`${V2}/playlists/pause`); + const res = await request.post(`${V2}/playlists/resume`); + expect(res.status()).toBe(200); + await deleteTestPlaylist(request); + }); + + test('POST /playlists/stopgracefully', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + await createTestPlaylist(request); + await startTestPlaylist(request); + const res = await request.post(`${V2}/playlists/stopgracefully`); + expect(res.status()).toBe(200); + await request.delete(`${V2}/playlist/${PLAYLIST_NAME}`); + }); + + test('POST /playlists/stopgracefullyafterloop', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + await createTestPlaylist(request); + await startTestPlaylist(request); + const res = await request.post(`${V2}/playlists/stopgracefullyafterloop`); + expect(res.status()).toBe(200); + await deleteTestPlaylist(request); + }); + +}); diff --git a/tests/playwright/tests/api-v2/plugin.spec.ts b/tests/playwright/tests/api-v2/plugin.spec.ts new file mode 100644 index 000000000..7c99b4ad7 --- /dev/null +++ b/tests/playwright/tests/api-v2/plugin.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('plugin', () => { + + test('GET /plugin/headerIndicators', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/plugin/headerIndicators`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /plugin', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/plugin`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('POST /plugin — install from URL', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: internet access and a valid plugin repository URL + test.skip(true, 'Requires internet access and a valid plugin repository URL'); + }); + + test('POST /plugin/fetchInfo', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: internet access and a valid plugin repository URL + test.skip(true, 'Requires internet access and a valid plugin repository URL'); + }); + + test('GET /plugin/:RepoName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a plugin to be installed on the device + test.skip(true, 'Requires an installed plugin'); + }); + + test('DELETE /plugin/:RepoName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a plugin to be installed on the device; irreversible without reinstall + test.skip(true, 'Requires an installed plugin; uninstall is irreversible without reinstall'); + }); + + test('GET /plugin/:RepoName/settings/:SettingName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: an installed plugin with a known setting name + test.skip(true, 'Requires an installed plugin with a known setting name'); + }); + + test('PUT /plugin/:RepoName/settings/:SettingName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: an installed plugin with a known writable setting name + test.skip(true, 'Requires an installed plugin with a known writable setting name'); + }); + + test('POST /plugin/:RepoName/settings/:SettingName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: an installed plugin with a known writable setting name + test.skip(true, 'Requires an installed plugin with a known writable setting name'); + }); + + test('POST /plugin/:RepoName/updates', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: an installed plugin and internet access + test.skip(true, 'Requires an installed plugin and internet access'); + }); + + test('POST /plugin/:RepoName/upgrade', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: an installed plugin with an available upgrade + test.skip(true, 'Requires an installed plugin with an available upgrade'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/proxies.spec.ts b/tests/playwright/tests/api-v2/proxies.spec.ts new file mode 100644 index 000000000..7d8c0f009 --- /dev/null +++ b/tests/playwright/tests/api-v2/proxies.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +// 192.0.2.1 is TEST-NET (RFC 5737), safe for dummy proxy tests +const TEST_PROXY_IP = '192.0.2.1'; + +test.describe('proxies', () => { + + test('GET /proxies', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'FULL' }); + const res = await request.get(`${V2}/proxies`); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual([]); + }); + + test('POST /proxies — set full list then restore', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const setRes = await request.post(`${V2}/proxies`, { + data: [TEST_PROXY_IP], + }); + expect(setRes.status()).toBe(200); + + const restoreRes = await request.post(`${V2}/proxies`, { data: [] }); + expect(restoreRes.status()).toBe(200); + }); + + test('DELETE /proxies — delete all after setup', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + await request.post(`${V2}/proxies`, { data: [TEST_PROXY_IP] }); + + const res = await request.delete(`${V2}/proxies`); + expect(res.status()).toBe(200); + }); + + test('POST /proxies/:ProxyIp then DELETE — add then remove', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const add = await request.post(`${V2}/proxies/${TEST_PROXY_IP}`); + expect(add.status()).toBe(200); + + const del = await request.delete(`${V2}/proxies/${TEST_PROXY_IP}`); + expect(del.status()).toBe(200); + }); + + test('GET /system/proxies', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/system/proxies`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('POST /system/proxies — set full list then restore', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const setRes = await request.post(`${V2}/system/proxies`, { + data: [TEST_PROXY_IP], + }); + expect(setRes.status()).toBe(200); + + const restoreRes = await request.post(`${V2}/system/proxies`, { data: [] }); + expect(restoreRes.status()).toBe(200); + }); + +}); diff --git a/tests/playwright/tests/api-v2/remotes.spec.ts b/tests/playwright/tests/api-v2/remotes.spec.ts new file mode 100644 index 000000000..b18ea2079 --- /dev/null +++ b/tests/playwright/tests/api-v2/remotes.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('remotes', () => { + + test('GET /remotes', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/remotes`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /proxy/:Ip/:urlPart', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a reachable remote FPP instance at a known IP address + test.skip(true, 'Requires a reachable remote FPP instance at a known IP'); + }); + + test('POST /remoteAction', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a reachable remote FPP instance; body: {"ip":"...","action":"..."} + test.skip(true, 'Requires a reachable remote FPP instance at a known IP'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/schedule.spec.ts b/tests/playwright/tests/api-v2/schedule.spec.ts new file mode 100644 index 000000000..6115e81dc --- /dev/null +++ b/tests/playwright/tests/api-v2/schedule.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('schedule', () => { + + test('GET /schedule', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/schedule`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + expect(body).toHaveProperty('scheduleEntries'); + expect(Array.isArray(body.scheduleEntries)).toBe(true); + }); + + test('POST /schedule — save current schedule back', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Read existing schedule and write it back unchanged to avoid data loss + const getRes = await request.get(`${V2}/schedule`); + expect(getRes.status()).toBe(200); + const original = await getRes.json(); + + const saveRes = await request.post(`${V2}/schedule`, { data: original }); + expect(saveRes.status()).toBe(200); + }); + + test('POST /schedule/reload', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const res = await request.post(`${V2}/schedule/reload`); + expect(res.status()).toBe(200); + }); + +}); diff --git a/tests/playwright/tests/api-v2/scripts.spec.ts b/tests/playwright/tests/api-v2/scripts.spec.ts new file mode 100644 index 000000000..e74d461bc --- /dev/null +++ b/tests/playwright/tests/api-v2/scripts.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('scripts', () => { + + test('GET /scripts', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/scripts`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('POST /scripts/installRemote/:category/:filename', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: internet access and valid remote script category/filename + test.skip(true, 'Requires internet access and a valid remote script category and filename'); + }); + + test('GET /scripts/viewRemote/:category/:filename', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: internet access and valid remote script category/filename + test.skip(true, 'Requires internet access and a valid remote script category and filename'); + }); + + test('GET /scripts/:scriptName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a script to exist on the device with a known name + test.skip(true, 'Requires a pre-existing script on the device'); + }); + + test('POST /scripts/:scriptName — save and clean up', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const scriptName = 'ci-test-script.sh'; + const saveRes = await request.post(`${V2}/scripts/${scriptName}`, { + data: { content: '#!/bin/bash\necho "ci test"\n' }, + }); + expect(saveRes.status()).toBe(200); + + // Clean up by deleting via file endpoint since scripts are stored as files + await request.delete(`${V2}/file/scripts/${scriptName}`); + }); + + test('POST /scripts/:scriptName/run', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a script to exist on the device with a known name + test.skip(true, 'Requires a pre-existing script on the device'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/sequence.spec.ts b/tests/playwright/tests/api-v2/sequence.spec.ts new file mode 100644 index 000000000..82ad96720 --- /dev/null +++ b/tests/playwright/tests/api-v2/sequence.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('sequence', () => { + + test('GET /sequence', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/sequence`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('POST /sequence/current/step', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a sequence currently paused in fppd + test.skip(true, 'Requires a paused sequence running in fppd'); + }); + + test('POST /sequence/current/stop', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a sequence currently running in fppd + test.skip(true, 'Requires a running sequence in fppd'); + }); + + test('POST /sequence/current/togglePause', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a sequence currently running in fppd + test.skip(true, 'Requires a running sequence in fppd'); + }); + + test('GET /sequence/:SequenceName', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a .fseq file to exist with a known name + test.skip(true, 'Requires a pre-existing .fseq sequence file on the device'); + }); + + test('GET /sequence/:SequenceName/meta', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a .fseq file to exist with a known name + test.skip(true, 'Requires a pre-existing .fseq sequence file on the device'); + }); + + test('POST /sequence/:SequenceName/start/:startSecond', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a .fseq file to exist; starting also requires fppd to be in a playable state + test.skip(true, 'Requires a pre-existing .fseq sequence file and fppd in playable state'); + }); + + test('POST /sequence/:SequenceName then DELETE — upload then remove', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Uploading a valid .fseq requires actual binary FSEQ content; skip rather than send invalid data + test.skip(true, 'Requires a valid binary .fseq file to upload'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/settings.spec.ts b/tests/playwright/tests/api-v2/settings.spec.ts new file mode 100644 index 000000000..bd49e2488 --- /dev/null +++ b/tests/playwright/tests/api-v2/settings.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('settings', () => { + + test('GET /settings', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/settings`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /settings/:SettingName', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/settings/fppMode`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('string'); + }); + + test('GET /settings/:SettingName/options', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/settings/fppMode/options`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('PUT /settings/:SettingName — change and restore', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Read current value of a safe, non-disruptive setting + const getRes = await request.get(`${V2}/settings/uiLevel`); + expect(getRes.status()).toBe(200); + const original = await getRes.json(); + + const putRes = await request.put(`${V2}/settings/uiLevel`, { data: original }); + expect(putRes.status()).toBe(200); + + // Restore original value + await request.put(`${V2}/settings/uiLevel`, { data: original }); + }); + + test('PUT /settings/:SettingName/jsonValueUpdate', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + // Requires: a JSON-type setting name and a valid update payload + test.skip(true, 'Requires knowledge of a JSON-type setting and valid update structure'); + }); + +}); diff --git a/tests/playwright/tests/api-v2/statistics.spec.ts b/tests/playwright/tests/api-v2/statistics.spec.ts new file mode 100644 index 000000000..e12e6f949 --- /dev/null +++ b/tests/playwright/tests/api-v2/statistics.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('statistics', () => { + + test('GET /statistics/usage', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/statistics/usage`); + expect(res.status()).toBe(200); + const body = await res.json(); + // May return null if no stats file exists yet + expect(body === null || typeof body === 'object').toBe(true); + }); + + test('POST /statistics/usage', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const res = await request.post(`${V2}/statistics/usage`); + expect(res.status()).toBe(200); + }); + + test('DELETE /statistics/usage', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const res = await request.delete(`${V2}/statistics/usage`); + expect(res.status()).toBe(200); + }); + +}); diff --git a/tests/playwright/tests/api-v2/system.spec.ts b/tests/playwright/tests/api-v2/system.spec.ts new file mode 100644 index 000000000..a3f188f03 --- /dev/null +++ b/tests/playwright/tests/api-v2/system.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('system', () => { + + test('GET /system/info', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/system/info`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('Version'); + expect(body).toHaveProperty('Hostname'); + expect(typeof body.Version).toBe('string'); + expect(typeof body.Hostname).toBe('string'); + }); + + test('GET /system/status', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/system/status`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('status'); + expect(typeof body.status).toBe('string'); + }); + + test('GET /system/updateStatus', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/system/updateStatus`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('GET /system/releaseNotes/:version', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/system/releaseNotes/current`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + }); + + test('GET /system/packages', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/system/packages`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /system/packages/info/:packageName', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/system/packages/info/fpp`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /system/fppd/skipBootDelay', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const res = await request.post(`${V2}/system/fppd/skipBootDelay`); + expect(res.status()).toBe(200); + }); + + test('GET /system/volume', { tag: ['@hardware:audio'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.get(`${V2}/system/volume`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /system/volume', { tag: ['@hardware:audio'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + const res = await request.post(`${V2}/system/volume`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /system/fppd/restart', { tag: ['@destructive'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'DESTRUCTIVE' }); + // Kills and restarts fppd — all in-flight playback stops + const res = await request.post(`${V2}/system/fppd/restart`); + expect([200, 202]).toContain(res.status()); + }); + + test('POST /system/fppd/start', { tag: ['@destructive'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'DESTRUCTIVE' }); + // Starts fppd — may return an error status if fppd is already running + const res = await request.post(`${V2}/system/fppd/start`); + expect([200, 202]).toContain(res.status()); + }); + + test('POST /system/fppd/stop', { tag: ['@destructive'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'DESTRUCTIVE' }); + // Kills fppd — all playback stops and the daemon goes offline + const res = await request.post(`${V2}/system/fppd/stop`); + expect([200, 202]).toContain(res.status()); + }); + + test('POST /system/reboot', { tag: ['@destructive'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'DESTRUCTIVE' }); + // Reboots the OS — SSH and all services become unavailable + const res = await request.post(`${V2}/system/reboot`); + expect([200, 202]).toContain(res.status()); + }); + + test('POST /system/shutdown', { tag: ['@destructive'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'DESTRUCTIVE' }); + // Shuts down the OS — device becomes completely unreachable + const res = await request.post(`${V2}/system/shutdown`); + expect([200, 202]).toContain(res.status()); + }); + +}); diff --git a/tests/playwright/tests/api-v2/testmode.spec.ts b/tests/playwright/tests/api-v2/testmode.spec.ts new file mode 100644 index 000000000..a7a2ee64a --- /dev/null +++ b/tests/playwright/tests/api-v2/testmode.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('testmode', () => { + + test('GET /testmode', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'FULL' }); + const res = await request.get(`${V2}/testmode`); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ enabled: false }); + }); + + test('POST /testmode — enable then disable', { tag: ['@state'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'STATE' }); + const enable = await request.post(`${V2}/testmode`, { + data: { enabled: true }, + }); + expect(enable.status()).toBe(200); + + const disable = await request.post(`${V2}/testmode`, { + data: { enabled: false }, + }); + expect(disable.status()).toBe(200); + }); + +}); diff --git a/tests/playwright/tests/api-v2/time.spec.ts b/tests/playwright/tests/api-v2/time.spec.ts new file mode 100644 index 000000000..8e66b0ffb --- /dev/null +++ b/tests/playwright/tests/api-v2/time.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('time', () => { + + test('GET /time', async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/time`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('utcDate'); + expect(body).toHaveProperty('localDate'); + expect(body).toHaveProperty('utcOffset'); + expect(typeof body.utcDate).toBe('string'); + expect(typeof body.localDate).toBe('string'); + }); + +}); diff --git a/www/api/MIGRATION.md b/www/api/MIGRATION.md new file mode 100644 index 000000000..5a8d97590 --- /dev/null +++ b/www/api/MIGRATION.md @@ -0,0 +1,100 @@ +# API Migration Guide + +This document records breaking changes to the FPP REST API surface. Each section covers +one phase of the ongoing API review. Entries are cumulative — newer phases are added at the +top. + +--- + +## HTTP Verb Corrections + +**Background:** HTTP `GET` is defined as a safe, idempotent method. Browsers, proxies, +prefetchers, and link crawlers routinely follow GET links speculatively and without user +intent. The routes listed in this section all mutate system state — they start or stop +playback, reboot hardware, run scripts, trigger events, or modify files — and were therefore +incorrectly exposed as `GET`. All have been moved to `POST` for the `/api/v2/` surface. + +The legacy `/api/` surface remains backward compatible where the merged router intentionally +preserves historical GET behavior. New clients should target `/api/v2/`. + +Unless noted otherwise, the only required client change is updating the HTTP method. +Parameters that were previously passed as URL query strings remain in the query string. +No request body is required. + +--- + +### System Control + +| Old | New | +| --- | --- | +| `GET /api/system/reboot` | `POST /api/v2/system/reboot` | +| `GET /api/system/shutdown` | `POST /api/v2/system/shutdown` | +| `GET /api/system/fppd/start` | `POST /api/v2/system/fppd/start` | +| `GET /api/system/fppd/stop` | `POST /api/v2/system/fppd/stop` | +| `GET /api/system/fppd/restart` | `POST /api/v2/system/fppd/restart` | + +### Playlist Control + +| Old | New | +| --- | --- | +| `GET /api/playlists/stop` | `POST /api/v2/playlists/stop` | +| `GET /api/playlists/pause` | `POST /api/v2/playlists/pause` | +| `GET /api/playlists/resume` | `POST /api/v2/playlists/resume` | +| `GET /api/playlists/stopgracefully` | `POST /api/v2/playlists/stopgracefully` | +| `GET /api/playlists/stopgracefullyafterloop` | `POST /api/v2/playlists/stopgracefullyafterloop` | +| `GET /api/playlist/{PlaylistName}/start` | `POST /api/v2/playlist/{PlaylistName}/start` | +| `GET /api/playlist/{PlaylistName}/start/{Repeat}` | `POST /api/v2/playlist/{PlaylistName}/start/{Repeat}` | +| `GET /api/playlist/{PlaylistName}/start/{Repeat}/{ScheduleProtected}` | `POST /api/v2/playlist/{PlaylistName}/start/{Repeat}/{ScheduleProtected}` | + +### Sequence Control + +| Old | New | +| --- | --- | +| `GET /api/sequence/{SequenceName}/start/{startSecond}` | `POST /api/v2/sequence/{SequenceName}/start/{startSecond}` | +| `GET /api/sequence/current/step` | `POST /api/v2/sequence/current/step` | +| `GET /api/sequence/current/togglePause` | `POST /api/v2/sequence/current/togglePause` | +| `GET /api/sequence/current/stop` | `POST /api/v2/sequence/current/stop` | + +### Event Triggering + +| Old | New | +| --- | --- | +| `GET /api/events/{eventId}/trigger` | `POST /api/v2/events/{eventId}/trigger` | + +### File Operations + +| Old | New | +| --- | --- | +| `GET /api/file/move/{fileName}` | `POST /api/v2/file/move/{fileName}` | +| `GET /api/file/onUpload/{ext}/**` | `POST /api/v2/file/onUpload/{ext}/**` | + +### Network Configuration + +| Old | New | +| --- | --- | +| `GET /api/network/interface/add/{interface}` | `POST /api/v2/network/interface/add/{interface}` | + +### Git Operations + +| Old | New | +| --- | --- | +| `GET /api/git/reset` | `POST /api/v2/git/reset` | + +### Script Execution + +| Old | New | +| --- | --- | +| `GET /api/scripts/{scriptName}/run` | `POST /api/v2/scripts/{scriptName}/run` | +| `GET /api/scripts/installRemote/{category}/{filename}` | `POST /api/v2/scripts/installRemote/{category}/{filename}` | + +### Plugin Management + +| Old | New | +| --- | --- | +| `GET /api/plugin/{RepoName}/upgrade` | `POST /api/v2/plugin/{RepoName}/upgrade` | + +### Remote Proxy + +| Old | New | +| --- | --- | +| `GET /api/remoteAction?ip=192.168.1.100&action=reboot` | `POST /api/v2/remoteAction` with JSON body | diff --git a/www/api/README.md b/www/api/README.md index 6b27e2c12..89887e62f 100644 --- a/www/api/README.md +++ b/www/api/README.md @@ -1,36 +1,42 @@ # FPP API Documentation The API documentation is generated from PHPDoc annotations in `controllers/*.php` and -served by [Scalar](https://scalar.com/) at `GET /api/` on any running FPP instance. +served by [Scalar](https://scalar.com/) from the merged router in `www/api/index.php`. +Unprefixed `GET /api/` serves the legacy v1 docs, while `GET /api/v2/` serves the v2 docs. ## Viewing the docs -Browse to `http:///api/` on a running FPP instance. +Browse to `http:///api/` for v1 or `http:///api/v2/` for v2 on a running FPP instance. -To serve the docs locally without a full FPP stack, regenerate `openapi.json` (see below) -and open it in any OpenAPI viewer, or run: +To serve the docs locally without a full FPP stack, regenerate the versioned spec you care +about (see below) and open it in any OpenAPI viewer, or run: ```bash -npx @scalar/cli serve openapi.json +npx @scalar/cli serve v1/openapi.json ``` --- -## Regenerating `openapi.json` +## Regenerating versioned specs The spec is generated by a Python 3 script — no third-party packages required. ```bash -cd www/api && python3 tools/generate_openapi.py && cd - +cd www/api && python3 tools/generate_openapi.py && python3 tools/generate_openapi_v2.py && cd - ``` -This scans every `controllers/*.php` file for `@route`-tagged docblocks and writes -`openapi.json`. Run it whenever you add or change an endpoint annotation. +This scans every `controllers/*.php` file for `@route`-tagged docblocks and writes: + +- `v1/openapi.json` for the legacy compatibility surface served at `/api/openapi.json` +- `v2/openapi.json` for the v2 surface served at `/api/v2/openapi.json` + +Run both whenever you add or change an endpoint annotation. To also lint the result: ```bash -npx @redocly/cli lint --config openapi.lint.yaml openapi.json +npx @redocly/cli lint --config openapi.lint.yaml v1/openapi.json +npx @redocly/cli lint --config openapi.lint.yaml v2/openapi.json ``` Lint rules are defined in `openapi.lint.yaml`. `security-defined` is disabled (FPP has no auth @@ -40,13 +46,14 @@ layer); `operation-operationId` and `tag-description` are warnings only. ## Plugin API injection -Installed plugins are automatically included in the live spec served at `GET /api/openapi.json`. +Installed plugins are automatically included in the live specs served at +`GET /api/openapi.json` and `GET /api/v2/openapi.json`. No plugin changes are needed — FPP reads each plugin's `getEndpoints*()` function (defined in `/media/plugins//api.php`) and synthesizes minimal OpenAPI path entries for every -registered route under `/plugin//`. +registered route under `/api/plugin//` and `/api/v2/plugin//`. -The static `openapi.json` on disk contains only the core FPP endpoints. Plugin paths are -merged in at request time by `ServeOpenApiSpec()` in `index.php`. +The static versioned OpenAPI files on disk contain only the core FPP endpoints. Plugin paths are +merged in at request time by `ServeOpenApiSpec_v1()` and `ServeOpenApiSpec_v2()` in `index.php`. --- diff --git a/www/api/api.html b/www/api/api.html deleted file mode 100644 index cf933ebde..000000000 --- a/www/api/api.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - FPP API - - - - - - diff --git a/www/api/api.php b/www/api/api.php index 743cba0dc..ef2f341db 100644 --- a/www/api/api.php +++ b/www/api/api.php @@ -17,6 +17,7 @@ include '../common/htmlMeta.inc'; include '../common/menuHead.inc'; ?> + - - - -"; - - foreach ($endpoints as $endpoint) { - $input = $endpoint[2]; - if ($input == '') { - $input = ' '; - } else if (preg_match('/{/', $input)) { - $input = json_encode(json_decode($input, true), JSON_PRETTY_PRINT); - $input = "
$input
\n"; - } - - $output = $endpoint[3]; - if ($output == '') { - $output = ' '; - } else if (preg_match('/[\[{]/', $output)) { - $output = json_encode(json_decode($output, true), JSON_PRETTY_PRINT); - $output = "
$output
\n"; - } - - $desc = $endpoint[0]; - if ((preg_match('/^GET \//', $endpoint[0])) && (!preg_match('/:/', $endpoint[0]))) { - $desc = sprintf( - "%s", - preg_replace('/^GET \//', '', $endpoint[0]), - $endpoint[0] - ); - } - - $h .= sprintf( - "\n", - $desc, - $endpoint[1], - $input, - $output - ); - } - $h .= "
EndpointDescriptionInput JSONOutput JSON
%s%s%s%s


-

FPPD Daemon Endpoints - these require FPPD to be running or a timeout error will occur

- - - "; - foreach ($fppEndpoints as $endpoint) { - $input = $endpoint[2]; - if ($input == '') { - $input = ' '; - } else if (preg_match('/{/', $input)) { - $input = json_encode(json_decode($input, true), JSON_PRETTY_PRINT); - $input = "
$input
\n"; - } - - $output = $endpoint[3]; - if ($output == '') { - $output = ' '; - } else if (preg_match('/[\[{]/', $output)) { - $output = json_encode(json_decode($output, true), JSON_PRETTY_PRINT); - $output = "
$output
\n"; - } - - $h .= sprintf( - "\n", - $endpoint[0], - $endpoint[1], - $input, - $output - ); - } - - return $h; -} - -?> From 99a2878fcce7666d20da574d3da58f6d97415f04 Mon Sep 17 00:00:00 2001 From: "Justin J. Novack" Date: Thu, 14 May 2026 09:46:07 -0400 Subject: [PATCH 08/11] fix: api VERB change only --- www/api/controllers/proxies.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/www/api/controllers/proxies.php b/www/api/controllers/proxies.php index 7f68171ab..31d9af383 100644 --- a/www/api/controllers/proxies.php +++ b/www/api/controllers/proxies.php @@ -7,7 +7,8 @@ * Proxies a named action to a remote FPP instance by IP address. * Supported actions: `listUpgrades`, `reboot`, `restartFppd`, `upgradeOS`. * - * @route GET /api/remoteAction + * @route POST /api/remoteAction + * @body {"ip": "192.168.1.100", "action": "reboot"} * @response 400 Invalid action * ```json * {"error": "Invalid action given: badaction"} @@ -16,8 +17,9 @@ function RemoteAction_v1() { global $settings; - $ip = htmlspecialchars(isset($_GET['ip']) ? $_GET['ip'] : null); - $action = htmlspecialchars(isset($_GET['action']) ? $_GET['action'] : null); + $body = json_decode(file_get_contents('php://input'), true); + $ip = htmlspecialchars(isset($body['ip']) ? $body['ip'] : ''); + $action = htmlspecialchars(isset($body['action']) ? $body['action'] : ''); $action_map = [ 'listUpgrades' => '/api/git/releases/os', From f67a30af46e56be667f2524a726805916e9757a4 Mon Sep 17 00:00:00 2001 From: "Justin J. Novack" Date: Thu, 14 May 2026 09:52:20 -0400 Subject: [PATCH 09/11] fix: add a helper to process body messaging --- www/api/controllers/proxies.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/api/controllers/proxies.php b/www/api/controllers/proxies.php index 31d9af383..a5a4cfa40 100644 --- a/www/api/controllers/proxies.php +++ b/www/api/controllers/proxies.php @@ -17,7 +17,7 @@ function RemoteAction_v1() { global $settings; - $body = json_decode(file_get_contents('php://input'), true); + $body = get_json_body(); $ip = htmlspecialchars(isset($body['ip']) ? $body['ip'] : ''); $action = htmlspecialchars(isset($body['action']) ? $body['action'] : ''); From 439473c1d4595a7912fbd2b32d901de729274668 Mon Sep 17 00:00:00 2001 From: "Justin J. Novack" Date: Wed, 27 May 2026 13:01:55 -0400 Subject: [PATCH 10/11] fix: change keywords due to versioning --- .claude/WWWAPI-GUIDELINES.md | 163 ++ CLAUDE.md | 6 +- www/api/MIGRATION.md | 4 + www/api/README.md | 93 +- www/api/controllers/backups.php | 33 +- www/api/controllers/cape.php | 33 +- www/api/controllers/channel.php | 18 +- www/api/controllers/configfile.php | 12 +- www/api/controllers/effects.php | 6 +- www/api/controllers/email.php | 6 +- www/api/controllers/events.php | 10 +- www/api/controllers/files.php | 47 +- www/api/controllers/git.php | 19 +- www/api/controllers/media.php | 9 +- www/api/controllers/network.php | 40 +- www/api/controllers/options.php | 3 +- www/api/controllers/pipewire_control.php | 48 +- www/api/controllers/playlist.php | 56 +- www/api/controllers/plugin.php | 31 +- www/api/controllers/pluginHeaders.php | 3 +- www/api/controllers/proxies.php | 25 +- www/api/controllers/schedule.php | 9 +- www/api/controllers/scripts.php | 20 +- www/api/controllers/sequence.php | 31 +- www/api/controllers/settings.php | 15 +- www/api/controllers/stats.php | 9 +- www/api/controllers/system.php | 47 +- www/api/controllers/testmode.php | 6 +- www/api/index.php | 18 + www/api/tools/generate_openapi_base.py | 395 +++ www/api/tools/generate_openapi_v1.py | 474 +--- www/api/tools/generate_openapi_v2.py | 384 +-- www/api/v1/openapi.json | 3132 +++++----------------- www/api/v2/openapi.json | 343 +-- 34 files changed, 1670 insertions(+), 3878 deletions(-) create mode 100644 .claude/WWWAPI-GUIDELINES.md create mode 100644 www/api/tools/generate_openapi_base.py diff --git a/.claude/WWWAPI-GUIDELINES.md b/.claude/WWWAPI-GUIDELINES.md new file mode 100644 index 000000000..4e348a3ae --- /dev/null +++ b/.claude/WWWAPI-GUIDELINES.md @@ -0,0 +1,163 @@ +# FPP Web API Guidelines + +These rules apply whenever you add, modify, or review routes in `www/api/`. Read this +file before making any changes to `index.php` or `controllers/*.php`. + +## Rule 1 — Route registration lives entirely in `index.php` + +- `dispatch_all(path, method, handler)` registers the route for **all active API + versions** in one call. Use it whenever the route has the **same HTTP verb** across all + versions being registered. +- Use **explicit** `dispatch_get()` / `dispatch_post()` (with the version-prefixed path) + only when a route must differ by HTTP verb between versions — for example when a + legacy version accepted GET for a state-mutating operation and a newer version corrects + it to POST. Always append inline comments identifying the version and verb: + `// legacy: GET` / `// v2: POST`. +- A single-version explicit dispatch is also valid for routes that exist only in one + version (e.g. a route present only in the legacy surface while it awaits deprecation). +- Group routes under a `// Resource Name` comment block that matches the resource area. +- **Never** `require_once` a controller — Limonade auto-loads every `controllers/*.php` + on `run()`. Only `controllers/helpers.php` is explicitly required at the top of + `index.php` because it is needed before routing starts. + +**Example — same verb across all versions:** + +```php +dispatch_all('/pipewire/control/status', 'get', 'PWCtl_GetStatus'); +``` + +**Example — verb differs between versions:** + +```php +dispatch_get('/system/reboot', 'RebootDevice'); // legacy: GET +dispatch_post('/v2/system/reboot', 'RebootDevice'); // v2: POST +``` + +**Example — exists only in legacy while pending deprecation:** + +```php +dispatch_get('/legacy/thing', 'LegacyThing'); // legacy only; deprecated +``` + +--- + +## Rule 2 — Handler naming: PascalCase with semantic verb prefix + +Public endpoint handlers (functions registered as route callbacks) use **PascalCase** with +a verb that describes the operation: + +| Verb prefix | When to use | +| --- | --- | +| `Get` / `List` | Read a resource or collection | +| `Post` / `Create` | Create a new resource | +| `Save` / `Update` / `Put` | Replace or update an existing resource | +| `Delete` / `Remove` | Delete a resource | +| `Apply` / `Reload` / `Restart` | Trigger a side-effect action | + +A module-scoped prefix (`PWCtl_`, `GitCtl_`) is acceptable when a controller file is a +self-contained facade, but the overall name must still be PascalCase after the prefix. + +Non-routed internal helpers may use `camelCase` or `snake_case` — they are never +dispatched, so naming style is relaxed. + +**Versioned handlers:** The **unversioned name is always the latest implementation.** +When a breaking change is introduced, rename the old handler to `FunctionName_vX` +(where `X` is the version it was current for), update its registrations to point to the +versioned name, and implement the new behavior in the unversioned `FunctionName`. This +way the current canonical implementation never carries a version suffix. + +--- + +## Rule 3 — Every dispatched function requires a PHPDoc block + +Minimum required doc block: + +```php +/** + * Short imperative title (becomes the OpenAPI summary). + * + * Optional longer description separated by a blank line. + * It can span as many lines as needed — the blank line between + * the summary and this paragraph is what separates them. + * All lines here form a single description in the generated spec. + * + * @route-v1 GET /resource/{Id} + * @route-v2 GET /resource/{Id} + * @response 200 Description of success payload + * ```json + * {"status": "OK", "id": 1} + * ``` + */ +function GetResource() { ... } +``` + +**Tag reference:** + +| Tag | Required | Notes | +| --- | --- | --- | +| `@route-vN METHOD /path/{Param}` | Yes — one per version | Prefix-free path. `{Param}` = OpenAPI path parameter (PascalCase). | +| `@response [statusCode] description` | Yes | Default status = 200 when omitted. Fenced body block is optional. | +| `@body {"json": "example"}` | POST/PUT only | Shared unless `@body-vN` overrides for a specific version. | +| `@param type name Description` | No | Documents a query parameter. | +| `@badge "Label" level` | No | Levels: `success`, `warning`, `critical`, `info`. | +| `@badge-vN "Label" level` | No | Version-specific badge (e.g. `@badge-v1 "DEPRECATED" warning`). | +| `@deprecated-vN` | No | Marks the route deprecated in version N's spec. | + +**Prose rules:** + +- One prose paragraph → becomes the description; summary falls back to the route slug. +- Two or more prose paragraphs → first = summary, rest = description. +- Path parameters must use `{PascalCase}` in `@route-vN` tags to match OpenAPI convention. + +--- + +## Rule 4 — Helper functions to use in controllers + +All three are available in every controller (Limonade + helpers.php): + +| Function | Source | Purpose | +| --- | --- | --- | +| `getJsonBody(bool $required = true)` | `controllers/helpers.php` | Decode POST body. Halts with 400 if required and missing/invalid. Returns `array\|null`. | +| `json($data)` | `lib/limonade.php` | Set `Content-Type: application/json` and return `json_encode($data)`. Always use for JSON responses. | +| `params('Key')` | `lib/limonade.php` | Return a route segment or query param value. | + +--- + +## Rule 5 — Rebuild OpenAPI specs after every annotation change + +Run the generator for every active API version from the `www/api/` directory: + +```bash +cd www/api && python3 tools/generate_openapi_v1.py && python3 tools/generate_openapi_v2.py +``` + +Add a new version-specific generator call whenever a new API version is introduced (see +`www/api/README.md` for how to add a version). The pattern is always: + +```bash +python3 tools/generate_openapi_v.py +``` + +- All generated `v/openapi.json` files are **tracked artifacts** — commit them along + with the controller change. +- **Never edit any `openapi.json` by hand.** The generator overwrites it completely. +- Optional lint: `npx @redocly/cli lint --config openapi.lint.yaml v/openapi.json` + +--- + +## Rule 6 — Version semantics + +The legacy API surface (the unprefixed `/api/` routes) is the compatibility layer and is +planned for eventual deprecation as a whole. Newer versions introduce corrections and new +capabilities; their scope is not bounded by what the legacy surface does. + +When a route's behavior, signature, or HTTP verb differs between versions: + +- Register each version explicitly with the appropriate verb and path prefix. +- Annotate with version-specific `@route-vN` tags reflecting the actual method and path. +- Use `@badge-vN "DEPRECATED" warning` and `@deprecated-vN` on any version-specific route + that is being phased out. + +Only split into separate handler functions (`Foo_vX` / `Foo`) when the parameter contract +**genuinely differs** between versions. The unversioned name (`Foo`) is always the latest +implementation; older registrations point to the versioned name (`Foo_vX`). diff --git a/CLAUDE.md b/CLAUDE.md index 2eff18fe2..328cfef29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ Run `SD/FPP_Install_Mac.sh` from a directory that will serve as the media direct ### Platform Build Configuration (`src/makefiles/platform/`) | Platform | File | Defines | Notes | -|----------|------|---------|-------| +| -------- | ---- | ------- | ----- | | Raspberry Pi | `pi.mk` | `PLATFORM_PI` | libgpiod, builds all external submodules, fppoled/fppcapedetect/fpprtc | | BeagleBone | `bb.mk` | `PLATFORM_BBB` or `PLATFORM_BB64` | PRU support, NEON SIMD (32-bit), fppoled/fppcapedetect | | macOS | `osx.mk` | `PLATFORM_OSX` | clang++, CoreAudio framework, `.dylib` extension | @@ -71,6 +71,10 @@ External plugins (`/media/plugins/`) are compiled separately and link against FP When designing HTML, CSS, or working within `www/`, read `.claude/FRONTEND-GUIDELINES.md` before generating any markup. +## Web API + +When adding, modifying, or reviewing routes in `www/api/`, read `.claude/WWWAPI-GUIDELINES.md` before making changes. + ## Configuration Formats - **Channel outputs**: `config/channeloutputs.json` — output type, startChannel, channelCount, per-output config diff --git a/www/api/MIGRATION.md b/www/api/MIGRATION.md index 5a8d97590..652b70921 100644 --- a/www/api/MIGRATION.md +++ b/www/api/MIGRATION.md @@ -4,6 +4,10 @@ This document records breaking changes to the FPP REST API surface. Each section one phase of the ongoing API review. Entries are cumulative — newer phases are added at the top. +The verb differences below are encoded directly in the controller docblocks via +`@route-v1` and `@route-v2` tags. See `www/api/README.md` for the full annotation +convention. + --- ## HTTP Verb Corrections diff --git a/www/api/README.md b/www/api/README.md index bea889a81..713a528ba 100644 --- a/www/api/README.md +++ b/www/api/README.md @@ -25,13 +25,33 @@ The spec is generated by a Python 3 script — no third-party packages required. cd www/api && python3 tools/generate_openapi_v1.py && python3 tools/generate_openapi_v2.py && cd - ``` -This scans every `controllers/*.php` file for `@route`-tagged docblocks and writes: +This scans every `controllers/*.php` file for `@route-vN`-tagged docblocks and writes: - `v1/openapi.json` for the legacy compatibility surface served at `/api/openapi.json` - `v2/openapi.json` for the v2 surface served at `/api/v2/openapi.json` Run both whenever you add or change an endpoint annotation. +### Adding a new API version (v3, v4, …) + +1. Create `v3/` directory and an `index.html` (copy from `v2/`). +2. Copy `tools/generate_openapi_v2.py` → `tools/generate_openapi_v3.py` and update: + - `version=3` + - `output='v3/openapi.json'` + - `server_url='/api/v3'` + - `server_desc='Local FPP instance (v3)'` + - `info_version='3.0'` +3. Add `@route-v3 METHOD /path` to every controller function that exists in v3. +4. For routes that change behavior or signature, add version-specific overrides + (`@response-v3`, `@body-v3`, `@badge-v3`) where the new version differs. +5. Add `@deprecated-v2` to routes being removed in v3 so the v2 spec marks them. +6. Run `python3 tools/generate_openapi_v3.py` to generate `v3/openapi.json`. +7. Wire up the new spec in `www/api/index.php` (router + `ServeOpenApiSpec_v3()`). +8. Add a row to `MIGRATION.md` describing what changed from v2 → v3. + +The shared logic in `tools/generate_openapi_base.py` requires no changes — it +already handles any version number passed to `main()`. + To also lint the result: ```bash @@ -59,9 +79,50 @@ merged in at request time by `ServeOpenApiSpec_v1()` and `ServeOpenApiSpec_v2()` ## Annotating endpoints -Every public endpoint function must have a PHPDoc block with at least `@route`. +Every public endpoint function must have a PHPDoc block with at least one `@route-vN` tag. Helper functions (not directly routed) use only `@param` and `@return`. +### Versioned route tags + +Route tags are **always version-suffixed** so each generator only picks up its own +version's routes. A function with no `@route-vN` tag is invisible to all generators. + +Paths are **prefix-free** — omit the `/api/` or `/api/v2/` server prefix. Write the +resource path only; the generator prepends the correct base URL. + +```php +/** + * Reboot the system + * + * @route-v1 GET /system/reboot + * @route-v2 POST /system/reboot + * @response 200 System rebooting + */ +function Reboot() { ... } +``` + +A route present in only one version: + +```php +/** + * New v2-only endpoint + * + * @route-v2 POST /widgets + * @response 200 Widget created + */ +function CreateWidget() { ... } +``` + +Mark a route deprecated in a specific version with `@deprecated-vN`: + +```php +/** + * @route-v1 GET /legacy/thing + * @deprecated-v1 + * @response 200 Deprecated + */ +``` + ### Summary and description The prose before the first `@` tag is split into paragraphs by blank lines. The number @@ -76,7 +137,8 @@ treated as a single paragraph. * Returns the playlist in FPP JSON format. If `?mergeSubs=1` is specified, * sub-playlists are recursively merged into the parent sections. * - * @route GET /api/playlist/{PlaylistName} + * @route-v1 GET /playlist/{PlaylistName} + * @route-v2 GET /playlist/{PlaylistName} */ ``` @@ -90,7 +152,8 @@ paragraphs are joined into the description. * Returns the playlist in FPP JSON format. If `?mergeSubs=1` is specified, * sub-playlists are recursively merged into the parent sections. * - * @route GET /api/playlist/{PlaylistName} + * @route-v1 GET /playlist/{PlaylistName} + * @route-v2 GET /playlist/{PlaylistName} */ ``` @@ -100,7 +163,8 @@ paragraphs are joined into the description. /** * One-sentence description of what this endpoint does. * - * @route GET /api/example/{Param} + * @route-v1 GET /example/{Param} + * @route-v2 GET /example/{Param} * @response 200 Success * ```json * {"status": "OK", "value": "..."} @@ -117,7 +181,7 @@ function GetExample() { ... } * * The name must be unique. Returns the created widget on success. * - * @route POST /api/widgets + * @route-v2 POST /widgets * @body {"name": "MyWidget"} * @response 200 Widget created * ```json @@ -135,11 +199,12 @@ function CreateWidget() { ... } | Tag | Required | Notes | | --- | --- | --- | -| `@route METHOD /api/path/{Param}` | Yes | `METHOD` is `GET`, `POST`, `PUT`, `DELETE`, or `PATCH`. The `/api/` prefix is required. `{Param}` becomes an OpenAPI path parameter. | -| `@response [statusCode] ` | Yes | Plain-text description. `statusCode` defaults to `200` if omitted. Follow with a fenced block for the body (see [Responses](#responses)). | -| `@body ` | No | JSON example for the request body. Omit for `GET`/`DELETE`. | -| `@param type name Description` | No | Adds an OpenAPI query parameter. `type` is `int`, `bool`, `float`, or `string`. Names matching a `{Param}` in the route are ignored (path params are auto-detected). | -| `@badge "Label" level` | No | Adds a colored badge to the operation. See [Badges](#badges) below. | +| `@route-vN METHOD /path/{Param}` | Yes (one per version) | `METHOD` is `GET`, `POST`, `PUT`, `DELETE`, or `PATCH`. Path is prefix-free. `{Param}` becomes an OpenAPI path parameter. | +| `@deprecated-vN` | No | Sets `deprecated: true` in version N's spec. | +| `@response [statusCode] ` | Yes | Shared across all versions unless overridden with `@response-vN`. `statusCode` defaults to `200` if omitted. | +| `@body ` | No | Request body example. Shared unless overridden with `@body-vN`. | +| `@param type name Description` | No | Query parameter. Shared across versions. | +| `@badge "Label" level` | No | Colored badge. Shared unless overridden with `@badge-vN`. | | `@return type Description` | No (helpers only) | Standard PHPDoc; ignored by the OpenAPI generator. | ### Responses @@ -209,7 +274,8 @@ Use `{CamelCase}` in the path. The generator promotes every `{...}` segment to a `path` parameter automatically. ```php - * @route GET /api/playlist/{PlaylistName}/item/{Index} + * @route-v1 GET /playlist/{PlaylistName}/item/{Index} + * @route-v2 GET /playlist/{PlaylistName}/item/{Index} ``` ### Query parameters @@ -222,7 +288,8 @@ Use `@param` to document query string parameters. The type maps to an OpenAPI sc * * Returns the playlist in FPP JSON format. * - * @route GET /api/playlist/{PlaylistName} + * @route-v1 GET /playlist/{PlaylistName} + * @route-v2 GET /playlist/{PlaylistName} * @param int mergeSubs Merge sub-playlists recursively into parent sections * @response {"name": "MyPlaylist", "mainPlaylist": []} */ diff --git a/www/api/controllers/backups.php b/www/api/controllers/backups.php index 37a13f23d..804d32ba3 100644 --- a/www/api/controllers/backups.php +++ b/www/api/controllers/backups.php @@ -107,7 +107,8 @@ function getAvailableBackupsFromDir($backupDir) * * Returns a list of full system backup files stored in the local `backups/` directory. * - * @route GET /api/backups/list + * @route-v1 GET /backups/list + * @route-v2 GET /backups/list * @response 200 List of backup directory names * ```json * ["/", "FPPDevP4", "FPPDevP4_2026_05_02"] @@ -127,7 +128,8 @@ function GetAvailableBackups() * * Returns a list of devices (e.g. USB drives, SSDs) attached to the system that can be used for backups. * - * @route GET /api/backups/devices + * @route-v1 GET /backups/devices + * @route-v2 GET /backups/devices * @response 200 List of available backup devices * ```json * [ @@ -155,7 +157,8 @@ function RetrieveAvailableBackupsDevices() * * Returns a list of full system backup files stored on the specified device (e.g. a USB drive). * - * @route GET /api/backups/list/{DeviceName} + * @route-v1 GET /backups/list/{DeviceName} + * @route-v2 GET /backups/list/{DeviceName} * @response 200 List of backup directory names on the device * ```json * ["/", "FPPDevP4", "FPPDevP4_2026_05_02"] @@ -261,7 +264,8 @@ function driveMountHelper($deviceName, $usercallback_function, $functionArgs = a * * Mounts the specified device to `/mnt/{MountLocation}` (defaults to `/mnt/api_mount`). * - * @route POST /api/backups/devices/mount/{DeviceName}/{MountLocation} + * @route-v1 POST /backups/devices/mount/{DeviceName}/{MountLocation} + * @route-v2 POST /backups/devices/mount/{DeviceName}/{MountLocation} * @response 200 Device mounted successfully * ```json * { @@ -320,7 +324,8 @@ function MountDevice() * * Unmounts the drive at `/mnt/{MountLocation}` (defaults to `/mnt/api_mount`). * - * @route POST /api/backups/devices/unmount/{DeviceName}/{MountLocation} + * @route-v1 POST /backups/devices/unmount/{DeviceName}/{MountLocation} + * @route-v2 POST /backups/devices/unmount/{DeviceName}/{MountLocation} * @response 200 Device unmounted successfully * ```json * { @@ -386,7 +391,8 @@ function UnmountDevice() * Returns a list of JSON configuration backups stored locally, or — if `jsonConfigBackupUSBLocation` * is set — a combined list from local storage and the configured USB device. * - * @route GET /api/backups/configuration/list + * @route-v1 GET /backups/configuration/list + * @route-v2 GET /backups/configuration/list * @response 200 List of available JSON configuration backups * ```json * [ @@ -511,7 +517,8 @@ function processJsonBackupFileDataHelper($json_config_backup_Data, $source_direc * Generates a new JSON settings backup for all settings areas. If an alternate backup location has been * set, the backup is also copied to that location. * - * @route POST /api/backups/configuration + * @route-v1 POST /backups/configuration + * @route-v2 POST /backups/configuration * @body "The describing comment to be added to the backup" * @response 200 Backup created successfully * ```json @@ -584,7 +591,8 @@ function MakeJSONBackup() * Available devices can be obtained from `/backups/devices`, or the currently configured device * is stored in the `jsonConfigBackupUSBLocation` setting. * - * @route GET /api/backups/configuration/list/{DeviceName} + * @route-v1 GET /backups/configuration/list/{DeviceName} + * @route-v2 GET /backups/configuration/list/{DeviceName} * @response 200 List of JSON backup filenames on the device * ```json * [ @@ -617,7 +625,8 @@ function GetAvailableJSONBackupsOnDevice(){ * (configured alternate device). `GET /api/backups/configuration/list` can be used to obtain valid * directory and filename combinations. * - * @route POST /api/backups/configuration/restore/{Directory}/{BackupFilename} + * @route-v1 POST /backups/configuration/restore/{Directory}/{BackupFilename} + * @route-v2 POST /backups/configuration/restore/{Directory}/{BackupFilename} * @body "all" * @response 200 Restore result * ```json @@ -727,7 +736,8 @@ function RestoreJsonBackup(){ * (configured alternate device). `GET /api/backups/configuration/list` can be used to obtain valid * directories and filenames. * - * @route GET /api/backups/configuration/{Directory}/{BackupFilename} + * @route-v1 GET /backups/configuration/{Directory}/{BackupFilename} + * @route-v2 GET /backups/configuration/{Directory}/{BackupFilename} * @response 200 Contents of the specified JSON Settings backup as a download. * ```json * { @@ -804,7 +814,8 @@ function DownloadJsonBackup(){ * (configured alternate device). `GET /api/backups/configuration/list` can be used to obtain valid * directories and filenames. * - * @route DELETE /api/backups/configuration/{Directory}/{BackupFilename} + * @route-v1 DELETE /backups/configuration/{Directory}/{BackupFilename} + * @route-v2 DELETE /backups/configuration/{Directory}/{BackupFilename} * @response 200 Backup deleted successfully * ```json * { diff --git a/www/api/controllers/cape.php b/www/api/controllers/cape.php index 9166f1473..762007f34 100644 --- a/www/api/controllers/cape.php +++ b/www/api/controllers/cape.php @@ -6,7 +6,8 @@ * Returns the cape information for the currently detected hardware cape * (from `cape-info` settings). * - * @route GET /api/cape + * @route-v1 GET /cape + * @route-v2 GET /cape * @response 200 Cape hardware information * ```json * { @@ -56,7 +57,8 @@ function GetCapeInfo() * * Returns a list of available cape EEPROM options for the current platform. * - * @route GET /api/cape/options + * @route-v1 GET /cape/options + * @route-v2 GET /cape/options * @response 200 Available cape EEPROM options * ```json * ["--None--", "F16-B", "F32-B", "F4-B", "F8-B", "F8-Bv2", "RGB-123"] @@ -230,7 +232,8 @@ function getSigningDataHelper($returnArray = false, $key = '', $order = '') * * Returns the cape EEPROM signing data payload for use with an external signing service. * - * @route GET /api/cape/eeprom/signingData/{key}/{order} + * @route-v1 GET /cape/eeprom/signingData/{key}/{order} + * @route-v2 GET /cape/eeprom/signingData/{key}/{order} * @response 200 EEPROM signing data payload * ```json * { @@ -257,7 +260,8 @@ function GetSigningData() * * Downloads the cape EEPROM signing data as a binary file attachment. * - * @route GET /api/cape/eeprom/signingFile/{key}/{order} + * @route-v1 GET /cape/eeprom/signingFile/{key}/{order} + * @route-v2 GET /cape/eeprom/signingFile/{key}/{order} * @response 200 EEPROM signing data as binary file attachment * ```bytes * [Content-Type: application/octet-stream] @@ -334,7 +338,8 @@ function signEEPROMHelper($data) * Signs the cape EEPROM by sending its data to the FalconPlayer.com signing API * using the provided `key` and order ID. * - * @route POST /api/cape/eeprom/sign/{key}/{order} + * @route-v1 POST /cape/eeprom/sign/{key}/{order} + * @route-v2 POST /cape/eeprom/sign/{key}/{order} * @response 200 EEPROM signed successfully * ```json * {"Status": "OK", "Message": "EEPROM Signed."} @@ -399,7 +404,8 @@ function SignEEPROM($key = '', $order = '') * Accepts a signed EEPROM data payload and writes it back to the cape EEPROM. * Accepts either a multipart file upload (`signingPacket`) or a raw JSON body. * - * @route POST /api/cape/eeprom/signingData + * @route-v1 POST /cape/eeprom/signingData + * @route-v2 POST /cape/eeprom/signingData * @body {"key": "ABCD-1234", "orderID": "42", "serial": "1000000012345678", "eeprom": ""} * @response 200 Signed EEPROM written successfully * ```json @@ -440,7 +446,8 @@ function PostSigningData() * Redeems a voucher code against the FalconPlayer.com signing API to obtain * a signing `key` and order ID. * - * @route POST /api/cape/eeprom/voucher + * @route-v1 POST /cape/eeprom/voucher + * @route-v2 POST /cape/eeprom/voucher * @body {"voucher": "XXXX-XXXX-XXXX-XXXX", "first_name": "John", "last_name": "Doe", "email": "john@example.com", "password": "secret"} * @response 200 Voucher redeemed successfully * ```json @@ -530,7 +537,8 @@ function RedeemVoucher() * * Returns a list of available string cape configuration `key` values. * - * @route GET /api/cape/strings + * @route-v1 GET /cape/strings + * @route-v2 GET /cape/strings * @response 200 Available string cape configuration keys * ```json * ["F16v3-strings", "F8v2-strings"] @@ -554,7 +562,8 @@ function GetCapeStringOptions() * * Returns a list of available LED panel cape configuration `key` values. * - * @route GET /api/cape/panel + * @route-v1 GET /cape/panel + * @route-v2 GET /cape/panel * @response 200 Available LED panel cape configuration keys * ```json * [] @@ -578,7 +587,8 @@ function GetCapePanelOptions() * * Returns the string cape configuration JSON for the specified `key`. * - * @route GET /api/cape/strings/{key} + * @route-v1 GET /cape/strings/{key} + * @route-v2 GET /cape/strings/{key} * @response 200 String cape configuration * ```json * { @@ -615,7 +625,8 @@ function GetCapeStringConfig() * * Returns the LED panel cape configuration JSON for the specified `key`. * - * @route GET /api/cape/panel/{key} + * @route-v1 GET /cape/panel/{key} + * @route-v2 GET /cape/panel/{key} * @response 200 LED panel cape configuration * ```json * {} diff --git a/www/api/controllers/channel.php b/www/api/controllers/channel.php index 44c2489f4..d34a1ea49 100644 --- a/www/api/controllers/channel.php +++ b/www/api/controllers/channel.php @@ -7,7 +7,8 @@ * Returns a meaningful error if the connection to `fppd` fails. * * @badge "FPP REQUIRED" critical - * @route GET /api/channel/input/stats + * @route-v1 GET /channel/input/stats + * @route-v2 GET /channel/input/stats * @response 200 E1.31/DDP channel input statistics * ```json * { @@ -46,7 +47,8 @@ function ChannelInputGetStats() * Resets the E1.31/DDP channel input statistics counters. * * @badge "FPP REQUIRED" critical - * @route DELETE /api/channel/input/stats + * @route-v1 DELETE /channel/input/stats + * @route-v2 DELETE /channel/input/stats * @response 200 Statistics reset * ```json * {"status": "OK"} @@ -73,7 +75,8 @@ function ChannelInputDeleteStats() * * Returns the current configuration of any output processors. * - * @route GET /api/channel/output/processors + * @route-v1 GET /channel/output/processors + * @route-v2 GET /channel/output/processors * @response 200 Current output processor configuration * ```json * { @@ -114,7 +117,8 @@ function ChannelGetOutputProcessors() * Overwrites the output processor settings file with a new configuration and * returns the saved configuration. * - * @route POST /api/channel/output/processors + * @route-v1 POST /channel/output/processors + * @route-v2 POST /channel/output/processors * @body {"outputProcessors": [{"type": "Brightness", "active": 0, "description": "", "start": 1, "count": 10, "brightness": 50, "gamma": 1}]} * @response 200 Current output processor configuration * ```json @@ -159,7 +163,8 @@ function ChannelSaveOutputProcessors() * `co-pwm`, and `co-bbbStrings`. Supports an optional `?ip=` query parameter to fetch from * a remote FPP instance. * - * @route GET /api/channel/output/{file} + * @route-v1 GET /channel/output/{file} + * @route-v2 GET /channel/output/{file} * @response 200 Channel output configuration file contents * ```json * {} @@ -224,7 +229,8 @@ function ChannelGetOutput() * Overwrites the specified output configuration file with the `POST` body * and returns the saved configuration. * - * @route POST /api/channel/output/{file} + * @route-v1 POST /channel/output/{file} + * @route-v2 POST /channel/output/{file} * @body "Format varies based on file" * @response 200 Saved configuration echoed back * ```json diff --git a/www/api/controllers/configfile.php b/www/api/controllers/configfile.php index 7687c4f0d..050b716ca 100644 --- a/www/api/controllers/configfile.php +++ b/www/api/controllers/configfile.php @@ -33,7 +33,8 @@ function getFilesInDir($dir, $subdir = '') * * Returns a list of config files in `/home/fpp/media/config` or an optional subdirectory. * - * @route GET /api/configfile + * @route-v1 GET /configfile + * @route-v2 GET /configfile * @response 200 Directory listing * ```json * { @@ -68,7 +69,8 @@ function GetConfigFileList($dir = '') * Returns the contents of a specific config file, or a directory listing if * the path resolves to a directory. * - * @route GET /api/configfile/** + * @route-v1 GET /configfile/** + * @route-v2 GET /configfile/** * @response 200 Raw config file contents * ```text * (Raw config file contents) @@ -94,7 +96,8 @@ function DownloadConfigFile() * Uploads or overwrites a config file in `/home/fpp/media/config`, creating any * necessary subdirectories. Accepts a multipart file upload or raw `POST` body. * - * @route POST /api/configfile/** + * @route-v1 POST /configfile/** + * @route-v2 POST /configfile/** * @body "(Raw config file contents)" * @response 200 File uploaded * ```json @@ -163,7 +166,8 @@ function UploadConfigFile() * * Deletes a config file from `/home/fpp/media/config`. * - * @route DELETE /api/configfile/** + * @route-v1 DELETE /configfile/** + * @route-v2 DELETE /configfile/** * @response 200 File deleted * ```json * {"Status": "OK", "Message": ""} diff --git a/www/api/controllers/effects.php b/www/api/controllers/effects.php index 96539ca97..939b18e41 100644 --- a/www/api/controllers/effects.php +++ b/www/api/controllers/effects.php @@ -5,7 +5,8 @@ * * Returns a list of effect (`*.eseq`) files available in the effects directory. * - * @route GET /api/effects + * @route-v1 GET /effects + * @route-v2 GET /effects * @response 200 List of effect filenames * ```json * ["rainbow", "twinkle"] @@ -35,7 +36,8 @@ function effects_list() * Returns a combined list of all effect (`*.eseq`) files from both the effects directory * and the sequences directory. * - * @route GET /api/effects/ALL + * @route-v1 GET /effects/ALL + * @route-v2 GET /effects/ALL * @response 200 Combined list of effect and sequence filenames * ```json * ["rainbow", "twinkle", "MySequence"] diff --git a/www/api/controllers/email.php b/www/api/controllers/email.php index f9533dd50..3140194ff 100644 --- a/www/api/controllers/email.php +++ b/www/api/controllers/email.php @@ -6,7 +6,8 @@ * * Configures outbound email using the existing settings. * - * @route POST /api/email/configure + * @route-v1 POST /email/configure + * @route-v2 POST /email/configure * @response 200 Email configured * ```json * {"Status": "OK", "Message": ""} @@ -31,7 +32,8 @@ function ConfigureEmail() { * * Sends a test email using the existing settings. * - * @route POST /api/email/test + * @route-v1 POST /email/test + * @route-v2 POST /email/test * @response 200 Test email sent * ```json * {"Status": "OK", "Message": ""} diff --git a/www/api/controllers/events.php b/www/api/controllers/events.php index 4ae1925a6..a0ea1ee4f 100644 --- a/www/api/controllers/events.php +++ b/www/api/controllers/events.php @@ -5,7 +5,8 @@ * * Returns a map of all event (`*.fevt`) files, keyed by event ID (filename without extension). * - * @route GET /api/events + * @route-v1 GET /events + * @route-v2 GET /events * @response 200 Map of all event files keyed by event ID * ```json * { @@ -45,7 +46,8 @@ function EventsList() * Returns the contents of a specific event file. If `{eventId}` is `ids`, returns a map * of event IDs to display names. * - * @route GET /api/events/{eventId} + * @route-v1 GET /events/{eventId} + * @route-v2 GET /events/{eventId} * @response 200 Event file contents * ```json * { @@ -94,7 +96,9 @@ function EventGet() * Triggers the specified event by sending a `Trigger Event` command to `fppd`. * * @badges "FPP REQUIRED" critical - * @route POST /api/events/{eventId}/trigger + * @route-v1 GET /events/{eventId}/trigger + * @route-v2 POST /events/{eventId}/trigger + * @badge-v1 "DEPRECATED" warning * @response 200 Event triggered * ```json * {"status": "OK"} diff --git a/www/api/controllers/files.php b/www/api/controllers/files.php index 3035643f0..22fb296c4 100644 --- a/www/api/controllers/files.php +++ b/www/api/controllers/files.php @@ -67,7 +67,8 @@ function mapDirectoryKey($dirName) * * Copies the specified file from `:source` to `:dest` within the given directory. * - * @route POST /api/file/{DirName}/copy/{source}/{dest} + * @route-v1 POST /file/{DirName}/copy/{source}/{dest} + * @route-v2 POST /file/{DirName}/copy/{source}/{dest} * @response 200 File copied successfully * ```json * { @@ -108,7 +109,8 @@ function FilesCopy() * * Renames the specified file from `:source` to `:dest` within the given directory. * - * @route POST /api/file/{DirName}/rename/{source}/{dest} + * @route-v1 POST /file/{DirName}/rename/{source}/{dest} + * @route-v2 POST /file/{DirName}/rename/{source}/{dest} * @response 200 File renamed successfully * ```json * { @@ -294,7 +296,8 @@ function getFilesHelper($dirName, $prefix = '') * * Returns a list of files in the specified media directory. * - * @route GET /api/files/{DirName} + * @route-v1 GET /files/{DirName} + * @route-v2 GET /files/{DirName} * @param bool nameOnly When `1`, return a flat array of filenames instead of the default object envelope * @response 200 Listing of files * ```json @@ -402,7 +405,8 @@ function GetSequenceFPS() * name, extension category, and file path are read from route parameters. * The metadata command is defined in the plugin's `pluginInfo.json`. * - * @route GET /api/file/info/{plugin}/{ext}/** + * @route-v1 GET /file/info/{plugin}/{ext}/** + * @route-v2 GET /file/info/{plugin}/{ext}/** * @response 200 Plugin-specific file information * ```json * {} @@ -484,7 +488,9 @@ function callPluginFileUploaded($dir, $filename) * Notifies any plugin that has registered an `onUpload` handler for the given * file extension. `:ext` is the extension category and `**` is the file path. * - * @route POST /api/file/onUpload/{ext}/** + * @route-v1 GET /file/onUpload/{ext}/** + * @route-v2 POST /file/onUpload/{ext}/** + * @badge-v1 "DEPRECATED" warning * @response 200 Plugin notified of upload * ```json * {"status": "OK"} @@ -540,7 +546,8 @@ function movePluginFile($uploadDir, $filename) * * Downloads the specified file from a media directory. * - * @route GET /api/file/{DirName}/** + * @route-v1 GET /file/{DirName}/** + * @route-v2 GET /file/{DirName}/** * @param int tail Return the last N lines instead of the whole file * @param bool play When `1`, set a playback-oriented content type instead of a forced attachment * @param bool attach When `1`, force attachment download for images @@ -710,7 +717,9 @@ function findFile($dir, $filename) * subfolder based on its extension, returning a status of `OK` or an error * message if not successful. * - * @route POST /api/file/move/{fileName} + * @route-v1 GET /file/move/{fileName} + * @route-v2 POST /file/move/{fileName} + * @badge-v1 "DEPRECATED" warning * @response 200 File moved to media directory * ```json * {"status": "OK"} @@ -807,7 +816,8 @@ function MoveFile() * directories) as a zip archive. `logs` and `config` are handled specially to * include system log and config files. * - * @route GET /api/files/zip/{DirNames} + * @route-v1 GET /files/zip/{DirNames} + * @route-v2 GET /files/zip/{DirNames} * @response 200 Binary file stream of the compressed system archive. * ```bytes * [Raw Binary Stream: application/zip] @@ -1115,7 +1125,8 @@ function removeDir(string $dir): void * Deletes the specified file or directory from a media directory. Validates * the resolved path against the allowed base directory to prevent path traversal. * - * @route DELETE /api/file/{DirName}/** + * @route-v1 DELETE /file/{DirName}/** + * @route-v2 DELETE /file/{DirName}/** * @response 200 File or directory deleted * ```json * { @@ -1207,8 +1218,10 @@ function emulatedFseekForBigFiles($fp, $pos) * delivers a chunk identified by `Upload-Name`, `Upload-Offset`, and `Upload-Length` * headers; when all chunks arrive, the file is assembled. * - * @route POST /api/file/{DirName} - * @route PATCH /api/file/{DirName} + * @route-v1 POST /file/{DirName} + * @route-v2 POST /file/{DirName} + * @route-v1 PATCH /file/{DirName} + * @route-v2 PATCH /file/{DirName} * @response 200 Upload chunk received * ```json * { @@ -1329,7 +1342,8 @@ function PatchFile() * * Uploads a file to the specified media directory. * - * @route POST /api/file/{DirName}/{Name} + * @route-v1 POST /file/{DirName}/{Name} + * @route-v2 POST /file/{DirName}/{Name} * @param int bs Block size used for fragmented uploads * @param int sb Starting block index used for fragmented uploads * @response 200 File uploaded successfully @@ -1448,7 +1462,8 @@ function getFileInfo(&$list, $dirName, $fileName, $prefix = '') * * Creates a subdirectory inside the specified media directory. * - * @route POST /api/dir/{DirName}/{SubDir} + * @route-v1 POST /dir/{DirName}/{SubDir} + * @route-v2 POST /dir/{DirName}/{SubDir} * @response 200 Subdirectory created * ```json * { @@ -1490,7 +1505,8 @@ function CreateDir() * * Deletes an empty subdirectory from the specified media directory. * - * @route DELETE /api/dir/{DirName}/{SubDir} + * @route-v1 DELETE /dir/{DirName}/{SubDir} + * @route-v2 DELETE /dir/{DirName}/{SubDir} * @response 200 Subdirectory deleted * ```json * { @@ -1532,7 +1548,8 @@ function DeleteDir() * Streams the tail of a log file using Server-Sent Events (SSE). Only works * for files in the `logs` directory. * - * @route GET /api/file/{DirName}/tailfollow/* + * @route-v1 GET /file/{DirName}/tailfollow/* + * @route-v2 GET /file/{DirName}/tailfollow/* * @param int lines Number of existing lines to seed into the stream, from 1 to 500, default 50 * @response 200 Success * ```text diff --git a/www/api/controllers/git.php b/www/api/controllers/git.php index 883a8dce1..39aaf180f 100644 --- a/www/api/controllers/git.php +++ b/www/api/controllers/git.php @@ -4,7 +4,8 @@ * * Returns a list of commits present in the `origin` (GitHub) but not in the local repository. * - * @route GET /api/git/originLog + * @route-v1 GET /git/originLog + * @route-v2 GET /git/originLog * @response 200 Commits in origin not yet in local * ```json * { @@ -57,7 +58,9 @@ function GetGitOriginLog() * Discard local changes * Performs a hard reset on the current branch, discarding any local changes. * - * @route POST /api/git/reset + * @route-v1 GET /git/reset + * @route-v2 POST /git/reset + * @badge-v1 "DEPRECATED" warning * @response 200 Reset complete * ```json * { @@ -84,7 +87,8 @@ function GitReset() * * Returns the status of the local git branch, including any dirty files. * - * @route GET /api/git/status + * @route-v1 GET /git/status + * @route-v2 GET /git/status * @response 200 Local repository status * ```json * { @@ -112,7 +116,8 @@ function GitStatus() * Returns lists of `.fppos` files available locally or on GitHub for the current platform. * If the `{All}` path parameter is `"all"`, returns all releases regardless of platform. * - * @route GET /api/git/releases/os/{All} + * @route-v1 GET /git/releases/os/{All} + * @route-v2 GET /git/releases/os/{All} * @response 200 Available OS release assets * ```json * { @@ -308,7 +313,8 @@ function GitOSReleaseNotes() * * Returns release asset size information from the GitHub `FalconChristmas/fpp` releases API. * - * @route GET /api/git/releases/sizes + * @route-v1 GET /git/releases/sizes + * @route-v2 GET /git/releases/sizes * @response 200 Release asset sizes * ```json * [ @@ -359,7 +365,8 @@ function GitOSReleaseSizes() * Returns an array of branches available to switch to, filtering out obsolete version branches * and Dependabot branches. * - * @route GET /api/git/branches + * @route-v1 GET /git/branches + * @route-v2 GET /git/branches * @response 200 Available local branches * ```json * ["master", "v7.3", "v7.2", "v7.1", "v7.0"] diff --git a/www/api/controllers/media.php b/www/api/controllers/media.php index 89146bfc4..13fd1402d 100644 --- a/www/api/controllers/media.php +++ b/www/api/controllers/media.php @@ -8,7 +8,8 @@ * * Returns a list of media files (includes both music and video files). * - * @route GET /api/media + * @route-v1 GET /media + * @route-v2 GET /media * @response 200 List of media filenames * ```json * ["Frosty.mp4", "Jingle_Bells.mp3"] @@ -41,7 +42,8 @@ function GetMedia() * * Returns the duration of a media item. * - * @route GET /api/media/{MediaName}/duration + * @route-v1 GET /media/{MediaName}/duration + * @route-v2 GET /media/{MediaName}/duration * @response 200 Media duration * ```json * { @@ -77,7 +79,8 @@ function GetMediaDuration() * * Returns metadata streams, codecs, profiles, type for a specific media file. * - * @route GET /api/media/{MediaName}/meta + * @route-v1 GET /media/{MediaName}/meta + * @route-v2 GET /media/{MediaName}/meta * @response 200 Media file metadata * ```json * { diff --git a/www/api/controllers/network.php b/www/api/controllers/network.php index fa786a608..e7911901e 100644 --- a/www/api/controllers/network.php +++ b/www/api/controllers/network.php @@ -8,7 +8,8 @@ * Returns detailed information about network interfaces, their IP addresses, * and Wi-Fi signal strength. * - * @route GET /api/network/interface + * @route-v1 GET /network/interface + * @route-v2 GET /network/interface * @response 200 Network interface details * ```json * [ @@ -43,7 +44,8 @@ function NetworkListInterfaces() * * Returns signal strength information for wireless network interfaces. * - * @route GET /api/network/wifi/strength + * @route-v1 GET /network/wifi/strength + * @route-v2 GET /network/wifi/strength * @response 200 Wi-Fi signal strength per interface * ```json * [ @@ -67,7 +69,8 @@ function NetworkWiFiStrength() * Returns information about Wi-Fi networks discoverable via the specified * `{interface}`. Networks without an SSID may appear in the list. * - * @route GET /api/network/wifi/scan/{interface} + * @route-v1 GET /network/wifi/scan/{interface} + * @route-v2 GET /network/wifi/scan/{interface} * @response 200 Discoverable Wi-Fi networks * ```json * { @@ -468,7 +471,8 @@ function NetworkWiFiStatus() * Removes interface persistent names by deleting systemd `.link` files and * restoring any USB ethernet adapter config files back to `eth*` names. * - * @route DELETE /api/network/persistentNames + * @route-v1 DELETE /network/persistentNames + * @route-v2 DELETE /network/persistentNames * @response 200 Persistent names removed * ```json * {"status": "OK"} @@ -519,7 +523,8 @@ function NetworkPersistentNamesDelete() * Creates interface persistent names by writing systemd `.link` files that * pin each interface's name to its MAC address. * - * @route POST /api/network/persistentNames + * @route-v1 POST /network/persistentNames + * @route-v2 POST /network/persistentNames * @response 200 Persistent names created * ```json * {"status": "OK", "interfaceCnt": 2} @@ -578,7 +583,8 @@ function NetworkPersistentNamesCreate() * Returns the current DNS configuration. If not configured, `status` will * be `Not Configured`. * - * @route GET /api/network/dns + * @route-v1 GET /network/dns + * @route-v2 GET /network/dns * @response 200 Current DNS configuration * ```json * { @@ -606,7 +612,8 @@ function NetworkGetDNS() * * Updates the DNS configuration. * - * @route POST /api/network/dns + * @route-v1 POST /network/dns + * @route-v2 POST /network/dns * @body {"DNS1": "192.168.50.1", "DNS2": "192.168.1.1"} * @response 200 DNS configuration updated * ```json @@ -653,7 +660,8 @@ function NetworkSaveDNS() * Returns the currently configured default gateway IP address. May be empty * when using DHCP. * - * @route GET /api/network/gateway + * @route-v1 GET /network/gateway + * @route-v2 GET /network/gateway * @response 200 Current default gateway * ```json * {"GATEWAY": "192.168.1.1"} @@ -687,7 +695,8 @@ function NetworkGetGateway() * * Saves the default gateway IP address to the `gateway` configuration file. * - * @route POST /api/network/gateway + * @route-v1 POST /network/gateway + * @route-v2 POST /network/gateway * @body {"GATEWAY": "192.168.1.1"} * @response 200 Default gateway saved * ```json @@ -725,7 +734,8 @@ function NetworkSaveGateway() * * Retrieves the current network interface configuration. * - * @route GET /api/network/interface/{interface} + * @route-v1 GET /network/interface/{interface} + * @route-v2 GET /network/interface/{interface} * @response 200 Network interface configuration * ```json * { @@ -864,7 +874,9 @@ function NetworkGetInterface() * Creates a new blank DHCP interface configuration file for the specified * network interface (e.g. `eth1`, `wlan0`). * - * @route POST /api/network/interface/add/{interface} + * @route-v1 GET /network/interface/add/{interface} + * @route-v2 POST /network/interface/add/{interface} + * @badge-v1 "DEPRECATED" warning * @response 200 DHCP interface created * ```json * {"status": "New Blank Interface created"} @@ -901,7 +913,8 @@ function NetworkAddInterface() * Updates the saved configuration for the specified `{interface}` but does * not restart the network. * - * @route POST /api/network/interface/{interface} + * @route-v1 POST /network/interface/{interface} + * @route-v2 POST /network/interface/{interface} * @body {"INTERFACE": "eth0", "PROTO": "static", "ADDRESS": "192.168.1.149", "NETMASK": "255.255.255.0", "GATEWAY": "192.168.1.1"} * @response 200 Interface configuration saved * ```json @@ -1013,7 +1026,8 @@ function NetworkSetInterface() * Applies the networking settings for the specified `{interface}` at the OS * level and restarts the interface. * - * @route POST /api/network/interface/{interface}/apply + * @route-v1 POST /network/interface/{interface}/apply + * @route-v2 POST /network/interface/{interface}/apply * @response 200 Networking configuration applied * ```json * {"status": "OK", "output": []} diff --git a/www/api/controllers/options.php b/www/api/controllers/options.php index e50165bf1..3c6322edd 100644 --- a/www/api/controllers/options.php +++ b/www/api/controllers/options.php @@ -555,7 +555,8 @@ function GetOptions_AES67Interface() * Returns the available options for the specified setting. * Supports `AudioMixerDevice`, `AudioOutput`, `AudioInput`, and other platform-specific option sets. * - * @route GET /api/options/{SettingName} + * @route-v1 GET /options/{SettingName} + * @route-v2 GET /options/{SettingName} * @response 200 Available options for the setting * ```json * {"Dummy": "0"} diff --git a/www/api/controllers/pipewire_control.php b/www/api/controllers/pipewire_control.php index afe22d931..2ce7fc9d5 100644 --- a/www/api/controllers/pipewire_control.php +++ b/www/api/controllers/pipewire_control.php @@ -290,7 +290,8 @@ function pwctl_not_pipewire_response() * of the PipeWire/WirePlumber/pulse units, and configured group counts. Use * this to discover capability before issuing control calls. * - * @route GET /api/pipewire/control/status + * @route-v1 GET /pipewire/control/status + * @route-v2 GET /pipewire/control/status * @response 200 PipeWire backend status * ```json * {"status":"OK","backend":"pipewire","pipewireActive":true,"simpleMode":false,"services":{"fpp-pipewire":"active","fpp-wireplumber":"active","fpp-pipewire-pulse":"active"},"outputGroupCount":2,"outputGroupsEnabled":2,"inputGroupCount":1,"inputGroupsEnabled":1} @@ -391,7 +392,8 @@ function pwctl_build_group_view($group, $liveSinks) * PipeWire (`volumeSource: "live"` when the sink is running, else the saved * config value with `volumeSource: "config"`). * - * @route GET /api/pipewire/control/groups + * @route-v1 GET /pipewire/control/groups + * @route-v2 GET /pipewire/control/groups * @response 200 Output groups with live runtime state * ```json * {"status":"OK","groups":[{"id":1,"name":"Front","enabled":true,"channels":2,"nodeName":"fpp_group_front","configVolume":100,"configMute":false,"liveVolume":80,"liveMute":false,"running":true,"state":"RUNNING","volumeSource":"live","members":[{"cardId":"S3","channels":2,"nodeName":"fpp_fx_g1_s3","configVolume":100,"liveVolume":75,"liveMute":false,"running":true,"volumeSource":"live"}]}]} @@ -414,7 +416,8 @@ function PWCtl_GetGroups() * Returns a single audio output group (by numeric group id) with its member * sound cards and live runtime volume/mute. * - * @route GET /api/pipewire/control/groups/{id} + * @route-v1 GET /pipewire/control/groups/{id} + * @route-v2 GET /pipewire/control/groups/{id} * @response 200 Output group with live runtime state * ```json * {"status":"OK","group":{"id":1,"name":"Front","enabled":true,"nodeName":"fpp_group_front","liveVolume":80,"liveMute":false,"running":true,"members":[]}} @@ -441,7 +444,8 @@ function PWCtl_GetGroup() * PipeWire sink. The value is applied live and persisted to the group * config so it survives a reboot. * - * @route POST /api/pipewire/control/groups/{id}/volume + * @route-v1 POST /pipewire/control/groups/{id}/volume + * @route-v2 POST /pipewire/control/groups/{id}/volume * @body {"volume": 80} * @response 200 Volume applied and persisted * ```json @@ -501,7 +505,8 @@ function PWCtl_SetGroupVolume() * explicit `mute` boolean, or `toggle: true` to flip the current state. * Applied live and persisted. * - * @route POST /api/pipewire/control/groups/{id}/mute + * @route-v1 POST /pipewire/control/groups/{id}/mute + * @route-v2 POST /pipewire/control/groups/{id}/mute * @body {"mute": true} * @response 200 Mute state applied and persisted * ```json @@ -570,7 +575,8 @@ function PWCtl_SetGroupMute() * group, addressed by its stable ALSA card id (e.g. `S3`). Applied live to * the member filter-chain sink and persisted. * - * @route POST /api/pipewire/control/groups/{id}/members/{cardId}/volume + * @route-v1 POST /pipewire/control/groups/{id}/members/{cardId}/volume + * @route-v2 POST /pipewire/control/groups/{id}/members/{cardId}/volume * @body {"volume": 75} * @response 200 Member volume applied and persisted * ```json @@ -639,7 +645,8 @@ function PWCtl_SetMemberVolume() * addressed by its ALSA card id. Provide `mute` (bool) or `toggle: true`. * Applied live and persisted. * - * @route POST /api/pipewire/control/groups/{id}/members/{cardId}/mute + * @route-v1 POST /pipewire/control/groups/{id}/members/{cardId}/mute + * @route-v2 POST /pipewire/control/groups/{id}/members/{cardId}/mute * @body {"toggle": true} * @response 200 Member mute state applied and persisted * ```json @@ -778,7 +785,8 @@ function pwctl_build_input_group_view($ig, $liveNodes) * (`volumeSource: "config"`) plus a live `running` flag indicating whether * the loopback node is currently active. * - * @route GET /api/pipewire/control/input-groups + * @route-v1 GET /pipewire/control/input-groups + * @route-v2 GET /pipewire/control/input-groups * @response 200 Input groups with member state * ```json * {"status":"OK","inputGroups":[{"id":1,"name":"Main Mix","enabled":true,"channels":2,"outputs":[1,2],"members":[{"index":0,"type":"fppd_stream","sourceId":"fppd_stream_1","name":"FPP Media","configVolume":100,"configMute":false,"running":true,"volumeSource":"config"}]}]} @@ -801,7 +809,8 @@ function PWCtl_GetInputGroups() * Returns a single input group by numeric id with its members and routing * targets. * - * @route GET /api/pipewire/control/input-groups/{id} + * @route-v1 GET /pipewire/control/input-groups/{id} + * @route-v2 GET /pipewire/control/input-groups/{id} * @response 200 Input group with member state * ```json * {"status":"OK","inputGroup":{"id":1,"name":"Main Mix","enabled":true,"channels":2,"outputs":[1],"members":[]}} @@ -843,7 +852,8 @@ function pwctl_find_input_member(&$data, $id, $memberIndex) * loopback via channelmix and persisted. Note: the primary fppd stream's * volume is governed by fppd itself, not a loopback. * - * @route POST /api/pipewire/control/input-groups/{id}/members/{memberIndex}/volume + * @route-v1 POST /pipewire/control/input-groups/{id}/members/{memberIndex}/volume + * @route-v2 POST /pipewire/control/input-groups/{id}/members/{memberIndex}/volume * @body {"volume": 60} * @response 200 Member volume applied and persisted * ```json @@ -909,7 +919,8 @@ function PWCtl_SetInputMemberVolume() * implemented by driving channelmix volume to 0 and unmute restores the * saved member volume. Provide `mute` (bool) or `toggle: true`. * - * @route POST /api/pipewire/control/input-groups/{id}/members/{memberIndex}/mute + * @route-v1 POST /pipewire/control/input-groups/{id}/members/{memberIndex}/mute + * @route-v2 POST /pipewire/control/input-groups/{id}/members/{memberIndex}/mute * @body {"mute": true} * @response 200 Member mute state applied and persisted * ```json @@ -974,7 +985,8 @@ function PWCtl_SetInputMemberMute() * additionally reports the currently playing media filename and timing from * fppd. * - * @route GET /api/pipewire/control/streams + * @route-v1 GET /pipewire/control/streams + * @route-v2 GET /pipewire/control/streams * @response 200 Stream slot status * ```json * {"status":"OK","streams":[{"slot":1,"nodeName":"fppd_stream_1","status":"playing","mediaFilename":"show.mp4","secondsElapsed":12,"secondsRemaining":48},{"slot":2,"nodeName":"fppd_stream_2","status":"idle","mediaFilename":""}]} @@ -1021,7 +1033,8 @@ function PWCtl_GetStreams() * channelmix on the stream node. The response `control` field indicates * which path was used. * - * @route POST /api/pipewire/control/streams/{slot}/volume + * @route-v1 POST /pipewire/control/streams/{slot}/volume + * @route-v2 POST /pipewire/control/streams/{slot}/volume * @body {"volume": 90} * @response 200 Stream volume applied * ```json @@ -1079,7 +1092,8 @@ function PWCtl_SetStreamVolume() * connected state, per-path volume and mute. Values reflect the saved * config (`volumeSource: "config"`). * - * @route GET /api/pipewire/control/routing + * @route-v1 GET /pipewire/control/routing + * @route-v2 GET /pipewire/control/routing * @response 200 Routing matrix * ```json * {"status":"OK","volumeSource":"config","matrix":[{"inputGroupId":1,"inputGroupName":"Main Mix","enabled":true,"paths":[{"outputGroupId":1,"outputGroupName":"Front","connected":true,"volume":100,"mute":false},{"outputGroupId":2,"outputGroupName":"Rear","connected":false,"volume":75,"mute":false}]}]} @@ -1198,7 +1212,8 @@ function pwctl_persist_routing(&$igData, $igId, $ogId, $volume, $mute) * path. Applied live to the routing combine-stream and persisted to the * input-group routing config. * - * @route POST /api/pipewire/control/routing/{inputGroupId}/{outputGroupId}/volume + * @route-v1 POST /pipewire/control/routing/{inputGroupId}/{outputGroupId}/volume + * @route-v2 POST /pipewire/control/routing/{inputGroupId}/{outputGroupId}/volume * @body {"volume": 50} * @response 200 Route volume applied and persisted * ```json @@ -1245,7 +1260,8 @@ function PWCtl_SetRoutingVolume() * per-path volume. Provide `mute` (bool) or `toggle: true`. Persisted to the * input-group routing config. * - * @route POST /api/pipewire/control/routing/{inputGroupId}/{outputGroupId}/mute + * @route-v1 POST /pipewire/control/routing/{inputGroupId}/{outputGroupId}/mute + * @route-v2 POST /pipewire/control/routing/{inputGroupId}/{outputGroupId}/mute * @body {"toggle": true} * @response 200 Route mute state applied and persisted * ```json diff --git a/www/api/controllers/playlist.php b/www/api/controllers/playlist.php index eab1fd346..9379100ba 100644 --- a/www/api/controllers/playlist.php +++ b/www/api/controllers/playlist.php @@ -5,7 +5,8 @@ * * Get list of playlist names. * - * @route GET /api/playlists + * @route-v1 GET /playlists + * @route-v2 GET /playlists * @response 200 List of playlist names * ```json * ["Playlist_1", "Playlist_2", "Playlist_3"] @@ -167,7 +168,8 @@ function validatePlayListEntries(&$entries, &$media, &$playlist, &$rc) * Returns a list of all playlists with any validation errors, total item * counts, and total duration. * - * @route GET /api/playlists/validate + * @route-v1 GET /playlists/validate + * @route-v2 GET /playlists/validate * @response 200 Validation results for all playlists * ```json * [ @@ -268,7 +270,8 @@ function PlaylistListValidate() * * Get a combined list of playlist names and `*.fseq` sequence filenames that are playable. * - * @route GET /api/playlists/playable + * @route-v1 GET /playlists/playable + * @route-v2 GET /playlists/playable * @response 200 Playable playlist and sequence names * ```json * ["Playlist_1", "Playlist_2", "MySequence.fseq"] @@ -347,7 +350,8 @@ function cleanMedialNamesInPlaylist(&$playlistObj, $section) * * Insert a new playlist. * - * @route POST /api/playlists + * @route-v1 POST /playlists + * @route-v2 POST /playlists * @body {"name": "UploadTest", "globalPauseBetweenSequencesMS": 5000, "mainPlaylist": [{"type": "pause", "enabled": 1, "playOnce": 0, "duration": 8}], "playlistInfo": {"total_duration": 8, "total_items": 1}} * @response 200 Newly created playlist * ```json @@ -517,7 +521,8 @@ function getPlaylist($playlistName) * `?mergeSubs=1` is specified, sub-playlists are recursively merged into * the parent sections. * - * @route GET /api/playlist/{PlaylistName} + * @route-v1 GET /playlist/{PlaylistName} + * @route-v2 GET /playlist/{PlaylistName} * @param int mergeSubs Merge sub-playlsits recursively * @response 200 Playlist details * ```json @@ -554,7 +559,8 @@ function PlaylistGet() * * Update or Insert (upsert) the playlist named {PlaylistName}. * - * @route POST /api/playlist/{PlaylistName} + * @route-v1 POST /playlist/{PlaylistName} + * @route-v2 POST /playlist/{PlaylistName} * @body {"name": "UploadTest", "globalPauseBetweenSequencesMS": 5000, "mainPlaylist": [{"type": "pause", "enabled": 1, "playOnce": 0, "duration": 8}], "playlistInfo": {"total_duration": 8, "total_items": 1}} * @response 200 Updated playlist * ```json @@ -637,7 +643,8 @@ function PlaylistUpdate() * * Delete the playlist named {PlaylistName}. * - * @route DELETE /api/playlist/{PlaylistName} + * @route-v1 DELETE /playlist/{PlaylistName} + * @route-v2 DELETE /playlist/{PlaylistName} * @response 200 Playlist deleted * ```json * {"Status": "OK", "Message": ""} @@ -676,7 +683,8 @@ function PlaylistDelete() * * Insert an item into the `{SectionName}` section of playlist `{PlaylistName}`. * - * @route POST /api/playlist/{PlaylistName}/{SectionName}/item + * @route-v1 POST /playlist/{PlaylistName}/{SectionName}/item + * @route-v2 POST /playlist/{PlaylistName}/{SectionName}/item * @body {"type": "pause", "enabled": 1, "playOnce": 0, "duration": 8} * @response 200 Item inserted * ```json @@ -736,7 +744,9 @@ function PlaylistSectionInsertItem() * Immediately stop the currently running playlist. * * @badge "FPP REQUIRED" critical - * @route POST /api/playlists/stop + * @route-v1 GET /playlists/stop + * @route-v2 POST /playlists/stop + * @badge-v1 "DEPRECATED" warning * @response 200 Playlist stopped * ```json * {"Status": "OK", "Message": ""} @@ -761,7 +771,9 @@ function PlaylistStop() * Gracefully stop the currently running playlist. * * @badge "FPP REQUIRED" critical - * @route POST /api/playlists/stopgracefully + * @route-v1 GET /playlists/stopgracefully + * @route-v2 POST /playlists/stopgracefully + * @badge-v1 "DEPRECATED" warning * @response 200 Graceful stop initiated * ```json * {"Status": "OK", "Message": ""} @@ -787,7 +799,9 @@ function PlaylistStopGracefully() * current loop. * * @badge "FPP REQUIRED" critical - * @route POST /api/playlists/stopgracefullyafterloop + * @route-v1 GET /playlists/stopgracefullyafterloop + * @route-v2 POST /playlists/stopgracefullyafterloop + * @badge-v1 "DEPRECATED" warning * @response 200 Stop after loop initiated * ```json * {"Status": "OK", "Message": ""} @@ -814,7 +828,9 @@ function PlaylistStopGracefullyAfterLoop() * this playlist. * * @badge "FPP REQUIRED" critical - * @route POST /api/playlist/{PlaylistName}/start + * @route-v1 GET /playlist/{PlaylistName}/start + * @route-v2 POST /playlist/{PlaylistName}/start + * @badge-v1 "DEPRECATED" warning * @response 200 Playlist started * ```json * {"Status": "OK", "Message": ""} @@ -844,7 +860,9 @@ function PlaylistStart() * scheduler from stopping this playlist. * * @badge "FPP REQUIRED" critical - * @route POST /api/playlist/{PlaylistName}/start/{Repeat} + * @route-v1 GET /playlist/{PlaylistName}/start/{Repeat} + * @route-v2 POST /playlist/{PlaylistName}/start/{Repeat} + * @badge-v1 "DEPRECATED" warning * @param bool scheduleProtected Prevent schedule from stopping this playlist * @response 200 Playlist started * ```json @@ -876,7 +894,9 @@ function PlaylistStartRepeat() * stop this playlist. * * @badge "FPP REQUIRED" critical - * @route POST /api/playlist/{PlaylistName}/start/{Repeat}/{ScheduleProtected} + * @route-v1 GET /playlist/{PlaylistName}/start/{Repeat}/{ScheduleProtected} + * @route-v2 POST /playlist/{PlaylistName}/start/{Repeat}/{ScheduleProtected} + * @badge-v1 "DEPRECATED" warning * @response 200 Playlist started * ```json * {"Status": "OK", "Message": ""} @@ -905,7 +925,9 @@ function PlaylistStartRepeatProtected() * Pause the currently running playlist. * * @badge "FPP REQUIRED" critical - * @route POST /api/playlists/pause + * @route-v1 GET /playlists/pause + * @route-v2 POST /playlists/pause + * @badge-v1 "DEPRECATED" warning * @response 200 Playlist paused * ```json * {"Status": "OK", "Message": ""} @@ -930,7 +952,9 @@ function PlaylistPause() * Resume a previously paused playlist. * * @badge "FPP REQUIRED" critical - * @route POST /api/playlists/resume + * @route-v1 GET /playlists/resume + * @route-v2 POST /playlists/resume + * @badge-v1 "DEPRECATED" warning * @response 200 Playlist resumed * ```json * {"Status": "OK", "Message": ""} diff --git a/www/api/controllers/plugin.php b/www/api/controllers/plugin.php index 55e2ebb1a..a66f3195c 100644 --- a/www/api/controllers/plugin.php +++ b/www/api/controllers/plugin.php @@ -109,7 +109,8 @@ function CleanupPartialPluginInstall($plugin, $linkName = null) * * Get list of installed plugins. * - * @route GET /api/plugin + * @route-v1 GET /plugin + * @route-v2 GET /plugin * @response 200 List of installed plugin names * ```json * ["fpp-brightness", "fpp-matrixtools", "fpp-vastfmt"] @@ -144,7 +145,8 @@ function GetInstalledPlugins() * with `branch` and `sha` fields added to specify which branch and commit * to install. * - * @route POST /api/plugin + * @route-v1 POST /plugin + * @route-v2 POST /plugin * @body {"repoName": "fpp-matrixtools", "name": "MatrixTools", "author": "Chris Pinkham (CaptainMurdoch)", "srcURL": "https://github.com/cpinkham/fpp-matrixtools.git", "branch": "master", "sha": ""} * @response 200 Plugin installed * ```json @@ -718,7 +720,8 @@ function ComparePluginFPPVersions($a, $b) * `updatesAvailable` field indicates whether the plugin has commits that * have been fetched but not yet merged. * - * @route GET /api/plugin/{RepoName} + * @route-v1 GET /plugin/{RepoName} + * @route-v2 GET /plugin/{RepoName} * @response 200 Plugin information * ```json * { @@ -835,7 +838,8 @@ function PluginServeIcon() * * Uninstall plugin {RepoName}. * - * @route DELETE /api/plugin/{RepoName} + * @route-v1 DELETE /plugin/{RepoName} + * @route-v2 DELETE /plugin/{RepoName} * @response 200 Plugin uninstalled * ```json * {"Status": "OK", "Message": ""} @@ -906,7 +910,8 @@ function UninstallPlugin() * Check plugin `{RepoName}` for available updates by running `git fetch` in * the plugin directory and checking for any unmerged commits. * - * @route POST /api/plugin/{RepoName}/updates + * @route-v1 POST /plugin/{RepoName}/updates + * @route-v2 POST /plugin/{RepoName}/updates * @response 200 Update check result * ```json * {"Status": "OK", "Message": "", "updatesAvailable": 1} @@ -940,7 +945,9 @@ function CheckForPluginUpdates() * Pull in git updates for plugin `{RepoName}`. Supports an optional * `?stream=true` query parameter for streaming output. * - * @route POST /api/plugin/{RepoName}/upgrade + * @route-v1 GET /plugin/{RepoName}/upgrade + * @route-v2 POST /plugin/{RepoName}/upgrade + * @badge-v1 "DEPRECATED" warning * @param bool stream When `true`, stream the upgrade output to the response instead of buffering it * @response 200 Plugin upgraded * ```json @@ -1146,7 +1153,8 @@ function fetchURLWithGitHubCredentials($url) * authenticate against private GitHub repositories using credentials * configured on the Developer settings page. * - * @route POST /api/plugin/fetchInfo + * @route-v1 POST /plugin/fetchInfo + * @route-v2 POST /plugin/fetchInfo * @body {"url": "https://example.com/pluginInfo.json", "useCredentials": 1} * @response 200 Plugin info fetched from remote URL * ```json @@ -1646,7 +1654,8 @@ function pluginHasUpdates($plugin) * * Returns the value of setting `{SettingName}` from plugin `{RepoName}`. * - * @route GET /api/plugin/{RepoName}/settings/{SettingName} + * @route-v1 GET /plugin/{RepoName}/settings/{SettingName} + * @route-v2 GET /plugin/{RepoName}/settings/{SettingName} * @response 200 Plugin setting value * ```json * {"status": "OK", "SettingName": "SettingValue"} @@ -1671,8 +1680,10 @@ function PluginGetSetting() * * Sets `{SettingName}` for plugin `{RepoName}` and returns the updated value. * - * @route POST /api/plugin/{RepoName}/settings/{SettingName} - * @route PUT /api/plugin/{RepoName}/settings/{SettingName} + * @route-v1 POST /plugin/{RepoName}/settings/{SettingName} + * @route-v2 POST /plugin/{RepoName}/settings/{SettingName} + * @route-v1 PUT /plugin/{RepoName}/settings/{SettingName} + * @route-v2 PUT /plugin/{RepoName}/settings/{SettingName} * @body SettingValue * @response 200 Plugin setting updated * ```json diff --git a/www/api/controllers/pluginHeaders.php b/www/api/controllers/pluginHeaders.php index 5a45d4027..b3811fffb 100644 --- a/www/api/controllers/pluginHeaders.php +++ b/www/api/controllers/pluginHeaders.php @@ -6,7 +6,8 @@ * Returns header indicator data (e.g., notification badges) from all installed plugins * that define a `headerIndicators.php` file. * - * @route GET /api/plugin/headerIndicators + * @route-v1 GET /plugin/headerIndicators + * @route-v2 GET /plugin/headerIndicators * @response 200 Plugin header indicator data * ```json * [ diff --git a/www/api/controllers/proxies.php b/www/api/controllers/proxies.php index a5a4cfa40..5ce0ba04f 100644 --- a/www/api/controllers/proxies.php +++ b/www/api/controllers/proxies.php @@ -7,7 +7,7 @@ * Proxies a named action to a remote FPP instance by IP address. * Supported actions: `listUpgrades`, `reboot`, `restartFppd`, `upgradeOS`. * - * @route POST /api/remoteAction + * @route-v1 POST /remoteAction * @body {"ip": "192.168.1.100", "action": "reboot"} * @response 400 Invalid action * ```json @@ -68,7 +68,7 @@ function RemoteAction_v1() * Proxies a named action to a remote FPP instance by IP address. * Supported actions: `listUpgrades`, `reboot`, `restartFppd`, `upgradeOS`. * - * @route POST /api/v2/remoteAction + * @route-v2 POST /remoteAction * @body {"ip": "192.168.1.100", "action": "reboot"} * @response 400 Invalid action * ```json @@ -192,7 +192,8 @@ function LoadProxyList() * Replaces the proxy list with the submitted array of `host`/`description` objects, * validates each entry, and triggers an Apache graceful reload. * - * @route POST /api/proxies + * @route-v1 POST /proxies + * @route-v2 POST /proxies * @body [{"host": "192.168.1.2", "description": "Mega Tree"}] * @response 200 Updated proxy list * ```json @@ -303,7 +304,8 @@ function WriteProxyFile($proxies) * * Returns the list of IP addresses this FPP instance can proxy. * - * @route GET /api/proxies + * @route-v1 GET /proxies + * @route-v2 GET /proxies * @response 200 Current proxy list * ```json * [ @@ -324,7 +326,8 @@ function GetProxies() * * Adds a single IP address to the FPP proxy list if it does not already exist. * - * @route POST /api/proxies/{ProxyIp} + * @route-v1 POST /proxies/{ProxyIp} + * @route-v2 POST /proxies/{ProxyIp} * @response 200 Updated proxy list * ```json * [ @@ -357,7 +360,8 @@ function AddProxy() * * Removes a single IP address from the FPP proxy list. * - * @route DELETE /api/proxies/{ProxyIp} + * @route-v1 DELETE /proxies/{ProxyIp} + * @route-v2 DELETE /proxies/{ProxyIp} * @response 200 Updated proxy list * ```json * [] @@ -392,7 +396,8 @@ function DeleteProxy() * link-local-only host stays selectable. * Multiple routable addresses for one device are left intact. * - * @route GET /api/remotes + * @route-v1 GET /remotes + * @route-v2 GET /remotes * @response 200 Known remote FPP systems * ```json * { @@ -484,7 +489,8 @@ function GetRemotes() * * Fetches a URL on a remote FPP instance via server-side proxy to avoid CSP restrictions. * - * @route GET /api/proxy/{Ip}/{urlPart} + * @route-v1 GET /proxy/{Ip}/{urlPart} + * @route-v2 GET /proxy/{Ip}/{urlPart} * @response 400 Invalid IP address * ```json * {"error": "Invalid IP address"} @@ -570,7 +576,8 @@ function getDHCPLeases() * Deletes all proxy entries by writing an empty `proxy-config.conf` and * triggering an Apache graceful reload. * - * @route DELETE /api/proxies + * @route-v1 DELETE /proxies + * @route-v2 DELETE /proxies * @response 200 All proxies deleted * ```json * [] diff --git a/www/api/controllers/schedule.php b/www/api/controllers/schedule.php index af54d7366..51c2fa9e1 100644 --- a/www/api/controllers/schedule.php +++ b/www/api/controllers/schedule.php @@ -7,7 +7,8 @@ * * Returns the current FPP schedule configuration from `schedule.json`. * - * @route GET /api/schedule + * @route-v1 GET /schedule + * @route-v2 GET /schedule * @response 200 Current schedule entries * ```json * [ @@ -44,7 +45,8 @@ function GetSchedule() { * * Saves the new schedule configuration to `schedule.json`. * - * @route POST /api/schedule + * @route-v1 POST /schedule + * @route-v2 POST /schedule * @body [{"day": 7, "enabled": 0, "endDate": "2099-12-31", "endTime": "23:00:00", "playlist": "Main Show", "repeat": 1, "startDate": "2014-01-01", "startTime": "17:00:00", "stopType": 0}] * @response 200 Saved schedule entries * ```json @@ -98,7 +100,8 @@ function SaveSchedule() { * Sends a reload command to `fppd` to re-read the schedule configuration. * * @badge "FPP REQUIRED" critical - * @route POST /api/schedule/reload + * @route-v1 POST /schedule/reload + * @route-v2 POST /schedule/reload * @response 200 Schedule reloaded * ```json * {"Status": "OK", "Message": ""} diff --git a/www/api/controllers/scripts.php b/www/api/controllers/scripts.php index b201116d3..f1b716f47 100644 --- a/www/api/controllers/scripts.php +++ b/www/api/controllers/scripts.php @@ -5,7 +5,8 @@ * * Returns a list of currently installed scripts. * - * @route GET /api/scripts + * @route-v1 GET /scripts + * @route-v2 GET /scripts * @response 200 List of installed script filenames * ```json * ["script1.sh", "script2.sh"] @@ -37,7 +38,8 @@ function ScriptsList() * * Returns the source code of an installed script. * - * @route GET /api/scripts/{scriptName} + * @route-v1 GET /scripts/{scriptName} + * @route-v2 GET /scripts/{scriptName} * @response 200 Script source code * ```text * The content of the script as a string @@ -61,7 +63,8 @@ function ScriptGet() * * Writes the `POST` request body to the file specified by `{scriptName}`. * - * @route POST /api/scripts/{scriptName} + * @route-v1 POST /scripts/{scriptName} + * @route-v2 POST /scripts/{scriptName} * @response 200 Script saved * ```json * { @@ -113,7 +116,9 @@ function ScriptSave() * * Runs a locally installed script. * - * @route POST /api/scripts/{scriptName}/run + * @route-v1 GET /scripts/{scriptName}/run + * @route-v2 POST /scripts/{scriptName}/run + * @badge-v1 "DEPRECATED" warning * @response 200 Script output * ```text * The output of the script as a String @@ -138,7 +143,8 @@ function ScriptRun() * * Returns the source code of a remote script from the script repository. * - * @route GET /api/scripts/viewRemote/{category}/{filename} + * @route-v1 GET /scripts/viewRemote/{category}/{filename} + * @route-v2 GET /scripts/viewRemote/{category}/{filename} * @response 200 Remote script source code * ```text * The content of the script as a string @@ -159,7 +165,9 @@ function ScriptsViewRemote() * * Installs a remote script from the script repository. * - * @route POST /api/scripts/installRemote/{category}/{filename} + * @route-v1 GET /scripts/installRemote/{category}/{filename} + * @route-v2 POST /scripts/installRemote/{category}/{filename} + * @badge-v1 "DEPRECATED" warning * @response 200 Remote script installed * ```json * {"status": "OK"} diff --git a/www/api/controllers/sequence.php b/www/api/controllers/sequence.php index 96483e2c6..64639fbf1 100644 --- a/www/api/controllers/sequence.php +++ b/www/api/controllers/sequence.php @@ -5,7 +5,8 @@ * * Returns a list of all `*.fseq` sequence files. * - * @route GET /api/sequence + * @route-v1 GET /sequence + * @route-v2 GET /sequence * @response 200 List of sequence names * ```json * ["GreatestShow", "StPatricksDay", "Valentine"] @@ -29,7 +30,8 @@ function GetSequences() * * Downloads the `*.fseq` file for the named sequence. * - * @route GET /api/sequence/{SequenceName} + * @route-v1 GET /sequence/{SequenceName} + * @route-v2 GET /sequence/{SequenceName} * @response 200 Raw FSEQ file download * ```bytes * [Content-Type: application/octet-stream] @@ -71,7 +73,8 @@ function GetSequence() * * Returns `name`, `version`, `id`, `time`, and other details from the `*.fseq` file for the named sequence. * - * @route GET /api/sequence/{SequenceName}/meta + * @route-v1 GET /sequence/{SequenceName}/meta + * @route-v2 GET /sequence/{SequenceName}/meta * @response 200 Sequence metadata * ```json * { @@ -124,7 +127,8 @@ function GetSequenceMetaData() * * Uploads a new `*.fseq` sequence file. * - * @route POST /api/sequence/{SequenceName} + * @route-v1 POST /sequence/{SequenceName} + * @route-v2 POST /sequence/{SequenceName} * @body "(Raw FSEQ file data)" * @response 200 Sequence uploaded * ```json @@ -161,7 +165,8 @@ function PostSequence() * * Deletes the named `*.fseq` sequence file. * - * @route DELETE /api/sequence/{SequenceName} + * @route-v1 DELETE /sequence/{SequenceName} + * @route-v2 DELETE /sequence/{SequenceName} * @response 200 Sequence deleted * ```json * {"Status": "OK", "Message": ""} @@ -197,7 +202,9 @@ function DeleteSequence() * @badge "FPP REQUIRED" critical * @badge "DEVELOPER ONLY" info * - * @route POST /api/sequence/{SequenceName}/start/{startSecond} + * @route-v1 GET /sequence/{SequenceName}/start/{startSecond} + * @route-v2 POST /sequence/{SequenceName}/start/{startSecond} + * @badge-v1 "DEPRECATED" warning * @response 200 Sequence started * ```json * { @@ -239,7 +246,9 @@ function GetSequenceStart() * If the sequence was paused via `sequence/current/togglePause`, steps the sequence forward one frame. * * @badge "FPP REQUIRED" critical - * @route POST /api/sequence/current/step + * @route-v1 GET /sequence/current/step + * @route-v2 POST /sequence/current/step + * @badge-v1 "DEPRECATED" warning * @response 200 Sequence stepped * ```json * {"status": "OK"} @@ -262,7 +271,9 @@ function GetSequenceStep() * * @badge "FPP REQUIRED" critical * @badge "DEVELOPER ONLY" info - * @route POST /api/sequence/current/togglePause + * @route-v1 GET /sequence/current/togglePause + * @route-v2 POST /sequence/current/togglePause + * @badge-v1 "DEPRECATED" warning * @response 200 Sequence play/pause toggled * ```json * {"status": "OK"} @@ -283,7 +294,9 @@ function GetSequenceTogglePause() * * @badge "FPP REQUIRED" critical * @badge "DEVELOPER ONLY" info - * @route POST /api/sequence/current/stop + * @route-v1 GET /sequence/current/stop + * @route-v2 POST /sequence/current/stop + * @badge-v1 "DEPRECATED" warning * @response 200 Sequence stopped * ```json * {"status": "OK"} diff --git a/www/api/controllers/settings.php b/www/api/controllers/settings.php index 9117ea3fe..ce8a6b779 100644 --- a/www/api/controllers/settings.php +++ b/www/api/controllers/settings.php @@ -18,7 +18,8 @@ function readSettingsJSON() * * Get info about a particular setting, including its current `value`. * - * @route GET /api/settings/{SettingName} + * @route-v1 GET /settings/{SettingName} + * @route-v2 GET /settings/{SettingName} * @response 200 Setting metadata and current value * ```json * { @@ -107,7 +108,8 @@ function GetSetting() * * Sets the value for a specific setting. * - * @route PUT /api/settings/{SettingName} + * @route-v1 PUT /settings/{SettingName} + * @route-v2 PUT /settings/{SettingName} * @body "0" * @response 200 Setting saved * ```json @@ -338,7 +340,8 @@ function ResetFanThermalTrips() * * Returns the `settings.json` metadata file as a JSON list of settings. * - * @route GET /api/settings + * @route-v1 GET /settings + * @route-v2 GET /settings * @response 200 All settings metadata * ```json * { @@ -366,7 +369,8 @@ function GetSettings() * * Returns the current system time as a formatted string. * - * @route GET /api/time + * @route-v1 GET /time + * @route-v2 GET /time * @response 200 Current system time * ```json * {"time": "Tue Apr 02 08:06:34 EDT 2019"} @@ -385,7 +389,8 @@ function GetTime() * * Updates a sub-value held as JSON within a setting's value. Only valid for settings stored as JSON. * - * @route PUT /api/settings/{SettingName}/jsonValueUpdate + * @route-v1 PUT /settings/{SettingName}/jsonValueUpdate + * @route-v2 PUT /settings/{SettingName}/jsonValueUpdate * @body "raw json of hierarchy to update" * @response 200 Sub-value updated * ```json diff --git a/www/api/controllers/stats.php b/www/api/controllers/stats.php index 0d39f4dcf..79211ee22 100644 --- a/www/api/controllers/stats.php +++ b/www/api/controllers/stats.php @@ -55,7 +55,8 @@ function stats_generate($statsFile) * if sharing statistics is enabled. A cached file is returned unless it is * more than 2 hours old or `?force=1` is passed, in which case it is regenerated. * - * @route GET /api/statistics/usage + * @route-v1 GET /statistics/usage + * @route-v2 GET /statistics/usage * @param int force bypass cache * @response 200 Usage statistics payload * ```json @@ -218,7 +219,8 @@ function stats_memory() * Transmits the statistics payload to the remote stats server configured in * the `statsPublishUrl` setting. * - * @route POST /api/statistics/usage + * @route-v1 POST /statistics/usage + * @route-v2 POST /statistics/usage * @response 200 Statistics transmitted * ```json * {"status": "OK", "uuid": "M2-xxxxxxxx-f67f-930d-56ee-7xxxxxxxxxx"} @@ -248,7 +250,8 @@ function stats_publish_stats_file() * * Deletes the cached statistics file. * - * @route DELETE /api/statistics/usage + * @route-v1 DELETE /statistics/usage + * @route-v2 DELETE /statistics/usage * @response 200 Statistics cache cleared * ```json * {"status": "OK"} diff --git a/www/api/controllers/system.php b/www/api/controllers/system.php index ae71ec09b..e97ec8ecb 100644 --- a/www/api/controllers/system.php +++ b/www/api/controllers/system.php @@ -8,8 +8,10 @@ * * Reboots the operating system. * - * @route POST /api/system/reboot + * @route-v1 GET /system/reboot + * @route-v2 POST /system/reboot * @response 200 Reboot initiated + * @badge-v1 "DEPRECATED" warning * ```json * {"status": "OK"} * ``` @@ -35,8 +37,10 @@ function RebootDevice() * * Executes a clean shutdown of the operating system. * - * @route POST /api/system/shutdown + * @route-v1 GET /system/shutdown + * @route-v2 POST /system/shutdown * @response 200 Shutdown initiated + * @badge-v1 "DEPRECATED" warning * ```json * {"status": "OK"} * ``` @@ -61,7 +65,9 @@ function SystemShutdownOS() * * Starts the `fppd` process idempotently (if it isn't already running). * - * @route POST /api/system/fppd/start + * @route-v1 GET /system/fppd/start + * @route-v2 POST /system/fppd/start + * @badge-v1 "DEPRECATED" warning * @response 200 fppd started * ```json * {"status": "OK"} @@ -116,7 +122,9 @@ function stopFPPDNoStatus() * * Stops the `fppd` process if it is running. * - * @route POST /api/system/fppd/stop + * @route-v1 GET /system/fppd/stop + * @route-v2 POST /system/fppd/stop + * @badge-v1 "DEPRECATED" warning * @response 200 fppd stopped * ```json * {"status": "OK"} @@ -135,7 +143,9 @@ function StopFPPD() * Restarts the `fppd` process. Pass `?quick=1` to reload some configuration without * a full restart. * - * @route POST /api/system/fppd/restart + * @route-v1 GET /system/fppd/restart + * @route-v2 POST /system/fppd/restart + * @badge-v1 "DEPRECATED" warning * @param int quick When `1`, send a reload signal to a running fppd instead of a full stop/start * @response 200 fppd restarted * ```json @@ -163,7 +173,8 @@ function RestartFPPD() * * Returns release notes for the specified FPP version tag from the GitHub releases API. * - * @route GET /api/system/releaseNotes/{version} + * @route-v1 GET /system/releaseNotes/{version} + * @route-v2 GET /system/releaseNotes/{version} * @response 200 Release notes * ```json * { @@ -201,7 +212,8 @@ function ViewReleaseNotes() * Returns the current FPP update/upgrade status, including whether a newer version is available, * the current commit, and any major version or end-of-life warnings. * - * @route GET /api/system/updateStatus + * @route-v1 GET /system/updateStatus + * @route-v2 GET /system/updateStatus * @response 200 FPP upgrade status * ```json * { @@ -416,7 +428,8 @@ function GetUpdateStatus() * * Sets the system volume. The new level should be passed as a JSON body. * - * @route POST /api/system/volume + * @route-v1 POST /system/volume + * @route-v2 POST /system/volume * @body {"volume": 34} * @response 200 Volume set * ```json @@ -448,7 +461,8 @@ function SystemSetAudio() * * Returns the current volume if `fppd` is running, or the `Volume` setting value if not. * - * @route GET /api/system/volume + * @route-v1 GET /system/volume + * @route-v2 GET /system/volume * @response 200 Current volume * ```json * {"status": "OK", "method": "FPPD", "volume": 70} @@ -489,7 +503,8 @@ function SystemGetAudio() * Pass an optional array of IP addresses (e.g. `&ip[]=192.168.0.1&ip[]=192.168.0.2`) to query * remote instances instead. * - * @route GET /api/system/status + * @route-v1 GET /system/status + * @route-v2 GET /system/status * @response 200 System status * ```json * { @@ -700,7 +715,8 @@ function SystemGetStatus() * * Returns basic information about the system. * - * @route GET /api/system/info + * @route-v1 GET /system/info + * @route-v2 GET /system/info * @response 200 System information * ```json * { @@ -824,7 +840,8 @@ function finalizeStatusJson($obj) * * Returns a list of all installed and available OS package names via `apt list --all-versions`. * - * @route GET /api/system/packages + * @route-v1 GET /system/packages + * @route-v2 GET /system/packages * @response 200 List of OS package names * ```json * ["apache2", "ffmpeg", "php"] @@ -856,7 +873,8 @@ function GetOSPackages() * * Returns description, dependencies, and installation status for the specified OS package. * - * @route GET /api/system/packages/info/{packageName} + * @route-v1 GET /system/packages/info/{packageName} + * @route-v2 GET /system/packages/info/{packageName} * @response 200 Package information * ```json * { @@ -913,7 +931,8 @@ function GetOSPackageInfo() * * Skips the current boot delay by creating a skip flag file, allowing FPP startup to proceed immediately. * - * @route POST /api/system/fppd/skipBootDelay + * @route-v1 POST /system/fppd/skipBootDelay + * @route-v2 POST /system/fppd/skipBootDelay * @response 200 Boot delay skip requested * ```json * {"status": "OK", "message": "Boot delay skip requested"} diff --git a/www/api/controllers/testmode.php b/www/api/controllers/testmode.php index ada29447a..203c8ae07 100644 --- a/www/api/controllers/testmode.php +++ b/www/api/controllers/testmode.php @@ -8,7 +8,8 @@ * Returns the current Test Mode configuration for this instance. * * @badge "FPP REQUIRED" critical - * @route GET /api/testmode + * @route-v1 GET /testmode + * @route-v2 GET /testmode * @response 200 Current Test Mode configuration * ```json * { @@ -33,7 +34,8 @@ function testMode_Get() * Sets the current Test Mode configuration for this instance. * * @badge "FPP REQUIRED" critical - * @route POST /api/testmode + * @route-v1 POST /testmode + * @route-v2 POST /testmode * @body {"mode": "RGBChase", "subMode": "RGBChase-RGB", "cycleMS": 1000, "colorPattern": "FF000000FF000000FF", "enabled": 1, "channelSet": "1-520", "channelSetType": "channelRange"} * @response 200 Test mode updated successfully * ```json diff --git a/www/api/index.php b/www/api/index.php index 3a9e47fd3..8dec73dc8 100644 --- a/www/api/index.php +++ b/www/api/index.php @@ -196,6 +196,24 @@ function dispatch_all(string $path, string $method, string $fn): void { dispatch_all('/pipewire/aes67/interfaces', 'get', 'GetAES67NetworkInterfaces'); dispatch_all('/pipewire/graph', 'get', 'GetPipeWireGraph'); +// PipeWire control facade — clean, ID-addressed, live-state 3rd-party API +dispatch_all('/pipewire/control/status', 'get', 'PWCtl_GetStatus'); +dispatch_all('/pipewire/control/groups', 'get', 'PWCtl_GetGroups'); +dispatch_all('/pipewire/control/groups/:id', 'get', 'PWCtl_GetGroup'); +dispatch_all('/pipewire/control/groups/:id/volume', 'post', 'PWCtl_SetGroupVolume'); +dispatch_all('/pipewire/control/groups/:id/mute', 'post', 'PWCtl_SetGroupMute'); +dispatch_all('/pipewire/control/groups/:id/members/:cardId/volume', 'post', 'PWCtl_SetMemberVolume'); +dispatch_all('/pipewire/control/groups/:id/members/:cardId/mute', 'post', 'PWCtl_SetMemberMute'); +dispatch_all('/pipewire/control/input-groups', 'get', 'PWCtl_GetInputGroups'); +dispatch_all('/pipewire/control/input-groups/:id', 'get', 'PWCtl_GetInputGroup'); +dispatch_all('/pipewire/control/input-groups/:id/members/:memberIndex/volume', 'post', 'PWCtl_SetInputMemberVolume'); +dispatch_all('/pipewire/control/input-groups/:id/members/:memberIndex/mute', 'post', 'PWCtl_SetInputMemberMute'); +dispatch_all('/pipewire/control/streams', 'get', 'PWCtl_GetStreams'); +dispatch_all('/pipewire/control/streams/:slot/volume', 'post', 'PWCtl_SetStreamVolume'); +dispatch_all('/pipewire/control/routing', 'get', 'PWCtl_GetRouting'); +dispatch_all('/pipewire/control/routing/:inputGroupId/:outputGroupId/volume', 'post', 'PWCtl_SetRoutingVolume'); +dispatch_all('/pipewire/control/routing/:inputGroupId/:outputGroupId/mute', 'post', 'PWCtl_SetRoutingMute'); + // Playlists dispatch_all('/playlists', 'get', 'PlaylistList'); dispatch_all('/playlists', 'post', 'PlaylistInsert'); diff --git a/www/api/tools/generate_openapi_base.py b/www/api/tools/generate_openapi_base.py new file mode 100644 index 000000000..58bd59d13 --- /dev/null +++ b/www/api/tools/generate_openapi_base.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +Shared OpenAPI generator logic for all FPP API versions. + +Imported by generate_openapi_v1.py, generate_openapi_v2.py, etc. + +Docblock tag reference: + @route-vN METHOD /path — route for version N (REQUIRED; path is prefix-free) + @body-vN JSON — version-specific request body (falls back to @body) + @response-vN STATUS DESC — version-specific response (falls back to @response) + @badge-vN "Label" level — version-specific badge (falls back to @badge) + @deprecated-vN — marks the operation deprecated: true in version N + @param TYPE NAME DESC — query parameter (shared across all versions) + +Paths in docblocks must be prefix-free: write /system/reboot, not +/api/system/reboot or /api/v2/system/reboot. The generator prepends the +server base URL; the OpenAPI spec paths are relative to the server entry. +""" + +import glob +import json +import re +import sys +from collections import defaultdict +from pathlib import Path + +BADGE_COLORS = { + 'success': '#2e7d32', + 'warning': '#b25e00', + 'critical': '#c62828', + 'info': '#546e7a', +} + +MEDIA_HINT_MAP = { + 'json': 'application/json', + 'text': 'text/plain', + 'bytes': 'application/octet-stream', + 'binary': 'application/octet-stream', + 'xml': 'application/xml', + 'html': 'text/html', +} + +DOCBLOCK_RE = re.compile(r'/\*\*(.*?)\*/', re.DOTALL) + +# Matches @response (unversioned) or @response-vN. +# Groups: (1) status code, (2) description, (3) media hint, (4) block content +_RESPONSE_RE_TMPL = ( + r'@response{suffix}(?:\s+(\d{{3}}))?\s+([^\n]+)' + r'(?:\n\s*```(\w+)\n(.*?)\n\s*```)?' +) + + +def _response_re(suffix=''): + return re.compile(_RESPONSE_RE_TMPL.format(suffix=suffix), re.DOTALL) + + +# --------------------------------------------------------------------------- +# PHPDoc parsing +# --------------------------------------------------------------------------- + +def strip_stars(block): + lines = [] + for line in block.splitlines(): + line = re.sub(r'^\s*\*\s?', '', line) + lines.append(line) + return '\n'.join(lines) + + +def parse_docblocks(php_source, version: int): + """ + Yield endpoint dicts for every docblock that has @route-vN for the + requested version. Tags without a version suffix are shared/fallback. + """ + route_re = re.compile(rf'@route-v{version}\s+(GET|POST|PUT|DELETE|PATCH)\s+(\S+)') + body_ver_re = re.compile(rf'@body-v{version}\s+(.+)') + body_re = re.compile(r'@body\s+(.+)') + depr_re = re.compile(rf'@deprecated-v{version}') + badge_ver_re = re.compile(rf'@badge-v{version}\s+"([^"]+)"\s+(\w+)') + badge_re = re.compile(r'@badge\s+"([^"]+)"\s+(\w+)') + resp_ver_re = _response_re(rf'-v{version}') + resp_re = _response_re() + + blocks = [strip_stars(m.group(1)) for m in DOCBLOCK_RE.finditer(php_source)] + + for text in blocks: + route_match = route_re.search(text) + if not route_match: + continue + + method = route_match.group(1).lower() + oapi_path = route_match.group(2) + + # Description / summary (everything before the first @tag) + desc_raw = text[:text.find('@')].strip() if '@' in text else text.strip() + paragraphs = [] + current = [] + for ln in desc_raw.splitlines(): + stripped = ln.strip() + if stripped: + current.append(stripped) + elif current: + paragraphs.append(' '.join(current)) + current = [] + if current: + paragraphs.append(' '.join(current)) + + if len(paragraphs) == 0: + summary = description = None + elif len(paragraphs) == 1: + summary = None + description = paragraphs[0] + else: + summary = paragraphs[0] + description = ' '.join(paragraphs[1:]) + + # Body: versioned wins over generic + bm = body_ver_re.search(text) or body_re.search(text) + body_raw = bm.group(1).strip() if bm else None + + # Deprecated flag + deprecated = bool(depr_re.search(text)) + + # Query params (shared; path params are derived from the route pattern) + path_param_names = set(extract_path_params(oapi_path)) + params = [] + for pm in re.finditer(r'@param\s+(\S+)\s+(\S+)\s*(.*)', text): + php_type, pname, pdesc = pm.group(1), pm.group(2), pm.group(3).strip() + if pname in path_param_names: + continue + type_map = { + 'int': 'integer', 'integer': 'integer', + 'bool': 'boolean', 'boolean': 'boolean', + 'float': 'number', 'number': 'number', + } + params.append({ + 'name': pname, + 'in': 'query', + 'required': False, + 'schema': {'type': type_map.get(php_type.lower(), 'string')}, + 'description': pdesc or None, + }) + + # Responses: versioned entries override generic ones by status code + def collect_responses(pattern): + result = {} + for rm in pattern.finditer(text): + status = int(rm.group(1)) if rm.group(1) else 200 + result[status] = ( + status, + rm.group(2).strip(), + rm.group(3), + rm.group(4).strip() if rm.group(4) is not None else None, + ) + return result + + resp_base = collect_responses(resp_re) + resp_base.update(collect_responses(resp_ver_re)) + responses = list(resp_base.values()) + + # Badges: versioned entries supplement / override generic ones by name + def collect_badges(pattern): + return [ + { + 'name': bm.group(1), + 'color': BADGE_COLORS.get(bm.group(2).lower(), BADGE_COLORS['info']), + } + for bm in pattern.finditer(text) + ] + + seen_names = {} + for b in collect_badges(badge_re) + collect_badges(badge_ver_re): + seen_names[b['name']] = b + badges = list(seen_names.values()) + + yield { + 'method': method, + 'path': oapi_path, + 'summary': summary, + 'description': description, + 'body_raw': body_raw, + 'deprecated': deprecated, + 'responses': responses, + 'badges': badges, + 'params': params, + } + + +def load_endpoints(controllers_glob, version): + endpoints = [] + for php_file in sorted(glob.glob(controllers_glob)): + source = open(php_file, encoding='utf-8', errors='replace').read() + for ep in parse_docblocks(source, version): + endpoints.append(ep) + endpoints.sort(key=lambda e: (e['path'], e['method'])) + return endpoints + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def parse_json_value(raw): + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return raw + + +def extract_mime_override(block_content, default_type): + """ + If the first line is a bracketed MIME-type hint such as + [Content-Type: text/event-stream] + return (mime_type, remaining_content). Otherwise return (default_type, block_content). + """ + if not block_content: + return default_type, block_content + lines = block_content.splitlines() + m = re.match(r'^\[.*?([a-z]+/[a-z][a-z0-9+.\-]*)\]', lines[0], re.IGNORECASE) + if m: + return m.group(1), '\n'.join(lines[1:]).strip() + return default_type, block_content + + +def extract_path_params(path): + return re.findall(r'\{([^}]+)\}', path) + + +def tag_from_path(path): + parts = [p for p in path.strip('/').split('/') if p and not p.startswith('{')] + return parts[0] if parts else 'general' + + +# --------------------------------------------------------------------------- +# Build OpenAPI dict +# --------------------------------------------------------------------------- + +def build_openapi(endpoints, server_url, server_desc, info_version): + tags = sorted(set(tag_from_path(e['path']) for e in endpoints)) + + spec = { + 'openapi': '3.0.3', + 'info': { + 'title': 'FPP API', + 'description': 'Falcon Player (FPP) REST API', + 'version': info_version, + }, + 'servers': [{'url': server_url, 'description': server_desc}], + 'tags': [{'name': t} for t in tags], + 'paths': {}, + } + + by_path = defaultdict(list) + for ep in endpoints: + by_path[ep['path']].append(ep) + + for path in sorted(by_path): + eps = by_path[path] + path_params = extract_path_params(path) + path_item = {} + + if path_params: + path_item['parameters'] = [ + {'name': p, 'in': 'path', 'required': True, 'schema': {'type': 'string'}} + for p in path_params + ] + + for ep in sorted(eps, key=lambda e: e['method']): + method = ep['method'] + tag = tag_from_path(path) + route_slug = path.lstrip('/') + summary = ep['summary'] if ep['summary'] else route_slug + + operation = { + 'tags': [tag], + 'summary': summary, + } + + if ep.get('deprecated'): + operation['deprecated'] = True + + if ep['description']: + operation['description'] = ep['description'] + + if ep['badges']: + operation['x-badges'] = ep['badges'] + + if ep['params']: + operation['parameters'] = [ + {k: v for k, v in p.items() if v is not None} + for p in ep['params'] + ] + + if ep['body_raw']: + body_val = parse_json_value(ep['body_raw']) + if isinstance(body_val, str): + content = { + 'text/plain': {'schema': {'type': 'string'}, 'example': body_val} + } + else: + content = { + 'application/json': { + 'schema': {'type': 'array' if isinstance(body_val, list) else 'object'}, + 'example': body_val, + } + } + operation['requestBody'] = {'content': content} + + responses = {} + if ep['responses']: + seen = set() + for status, desc, media_hint, block_content in ep['responses']: + if status in seen: + continue + seen.add(status) + + if media_hint is not None and block_content is not None: + content_type = MEDIA_HINT_MAP.get(media_hint, 'application/octet-stream') + + if media_hint == 'json': + parsed = parse_json_value(block_content) + schema = {'type': 'array' if isinstance(parsed, list) else 'object'} + example = parsed + if isinstance(parsed, str): + schema = {'type': 'string'} + example = parsed + responses[str(status)] = { + 'description': desc, + 'content': {content_type: {'schema': schema, 'example': example}}, + } + elif media_hint == 'bytes': + content_type, _ = extract_mime_override(block_content, content_type) + responses[str(status)] = { + 'description': desc, + 'content': { + content_type: {'schema': {'type': 'string', 'format': 'binary'}} + }, + } + else: + content_type, example = extract_mime_override(block_content, content_type) + responses[str(status)] = { + 'description': desc, + 'content': { + content_type: {'schema': {'type': 'string'}, 'example': example}, + }, + } + else: + val = parse_json_value(desc) + if isinstance(val, str): + responses[str(status)] = {'description': val} + else: + fallback_desc = 'Success' if status == 200 else f'HTTP {status}' + responses[str(status)] = { + 'description': fallback_desc, + 'content': { + 'application/json': { + 'schema': {'type': 'array' if isinstance(val, list) else 'object'}, + 'example': val, + } + }, + } + else: + responses['200'] = {'description': 'Success'} + + operation['responses'] = responses + path_item[method] = operation + + spec['paths'][path] = path_item + + return spec + + +# --------------------------------------------------------------------------- +# Entry point called by version-specific wrappers +# --------------------------------------------------------------------------- + +def main(*, version, output, server_url, server_desc, info_version): + controllers_glob = str( + Path(__file__).parent.parent / 'controllers' / '*.php' + ) + endpoints = load_endpoints(controllers_glob, version) + if not endpoints: + print( + f'ERROR: No @route-v{version} annotations found. ' + 'Are you running from www/api/?', + file=sys.stderr, + ) + sys.exit(1) + + spec = build_openapi(endpoints, server_url, server_desc, info_version) + out_path = Path(__file__).parent.parent / output + out_path.write_text(json.dumps(spec, indent=2), encoding='utf-8') + + path_count = len(set(e['path'] for e in endpoints)) + op_count = len(endpoints) + print(f'Written {out_path} ({path_count} paths, {op_count} operations)') diff --git a/www/api/tools/generate_openapi_v1.py b/www/api/tools/generate_openapi_v1.py index 6797edd8b..91eff8dee 100644 --- a/www/api/tools/generate_openapi_v1.py +++ b/www/api/tools/generate_openapi_v1.py @@ -1,476 +1,22 @@ #!/usr/bin/env python3 """ -Generate www/api/v1/openapi.json from @route/@body/@response PHPDoc tags +Generate www/api/v1/openapi.json from @route-v1 PHPDoc tags in www/api/controllers/*.php. Usage (run from www/api/): - python3 tools/generate_openapi.py - -Docblock summary/description rules: - - One prose line → description only; summary falls back to the route slug. - - Two+ prose lines → first line is summary, remainder joined as description. - -Badge syntax (multiple allowed, Scalar x-badges spec): - @badge "Label text" - Levels: success | warning | critical | info - Maps to {name, color} — Scalar uses color as the badge background. + python3 tools/generate_openapi_v1.py """ -import glob -import json -import re import sys -from collections import defaultdict from pathlib import Path -API_PREFIX = '/api' -CONTROLLERS = sorted(glob.glob(str(Path(__file__).parent.parent / 'controllers' / '*.php'))) - -# C++ daemon (fppd) sources that carry @route docblocks. These endpoints are -# served directly by fppd's embedded HTTP server (port 32322) and proxied under -# /api/* by Apache (see etc/apache2.site), so they never pass through a PHP -# controller. The @route docblock syntax is identical to the PHP controllers -# (C++ uses the same /** ... */ comment style), so the same parser handles both. -REPO_ROOT = Path(__file__).resolve().parents[3] -CPP_SOURCES = [ - REPO_ROOT / 'src' / 'httpAPI.cpp', # PlayerResource /fppd/* - REPO_ROOT / 'src' / 'OutputMonitor.cpp', # OutputMonitor /fppd/ports/* - REPO_ROOT / 'src' / 'channeltester' / 'ChannelTester.cpp', # ChannelTester /fppd/testing/* - REPO_ROOT / 'src' / 'overlays' / 'PixelOverlay.cpp', # PixelOverlayManager /models, /overlays/* - REPO_ROOT / 'src' / 'commands' / 'Commands.cpp', # CommandManager /command(s), /commandPresets - REPO_ROOT / 'src' / 'gpio.cpp', # GPIOManager /gpio/* - REPO_ROOT / 'src' / 'Player.cpp', # Player /player/* - REPO_ROOT / 'src' / 'Variables.cpp', # Variables /variables/* -] - -# Both PHP controllers and C++ daemon sources are parsed for @route docblocks. -SOURCES = CONTROLLERS + [str(p) for p in CPP_SOURCES if p.exists()] -OUTPUT = Path(__file__).parent.parent / 'v1' / 'openapi.json' - -ROUTELESS_DOC_PATHS = { - '/api/help', -} +sys.path.insert(0, str(Path(__file__).parent)) +from generate_openapi_base import main # noqa: E402 -V1_GET_COMPAT_PATHS = { - '/api/events/{eventId}/trigger', - '/api/file/onUpload/{ext}/**', - '/api/file/move/{fileName}', - '/api/git/reset', - '/api/network/interface/add/{interface}', - '/api/playlists/stop', - '/api/playlists/pause', - '/api/playlists/resume', - '/api/playlists/stopgracefully', - '/api/playlists/stopgracefullyafterloop', - '/api/playlist/{PlaylistName}/start', - '/api/playlist/{PlaylistName}/start/{Repeat}', - '/api/playlist/{PlaylistName}/start/{Repeat}/{ScheduleProtected}', - '/api/plugin/{RepoName}/upgrade', - '/api/sequence/{SequenceName}/start/{startSecond}', - '/api/sequence/current/step', - '/api/sequence/current/stop', - '/api/sequence/current/togglePause', - '/api/scripts/installRemote/{category}/{filename}', - '/api/scripts/{scriptName}/run', - '/api/system/fppd/restart', - '/api/system/fppd/start', - '/api/system/fppd/stop', - '/api/system/reboot', - '/api/system/shutdown', -} - -BADGE_COLORS = { - 'success': '#2e7d32', - 'warning': '#b25e00', - 'critical': '#c62828', - 'info': '#546e7a', -} - -MEDIA_HINT_MAP = { - 'json': 'application/json', - 'text': 'text/plain', - 'bytes': 'application/octet-stream', - 'binary': 'application/octet-stream', - 'xml': 'application/xml', - 'html': 'text/html', -} - -# Matches @response, its optional fenced code block, and stops before the next tag. -# Groups: (1) status code, (2) description, (3) media hint, (4) block content -RESPONSE_RE = re.compile( - r'@response(?:\s+(\d{3}))?\s+([^\n]+)' - r'(?:\n\s*```(\w+)\n(.*?)\n\s*```)?', - re.DOTALL, +main( + version=1, + output='v1/openapi.json', + server_url='/api/', + server_desc='Local FPP instance', + info_version='1.0', ) - - -# --------------------------------------------------------------------------- -# PHPDoc parsing -# --------------------------------------------------------------------------- - -DOCBLOCK_RE = re.compile(r'/\*\*(.*?)\*/', re.DOTALL) - - -def strip_stars(block): - lines = [] - for line in block.splitlines(): - line = re.sub(r'^\s*\*\s?', '', line) - lines.append(line) - return '\n'.join(lines) - - -def parse_docblocks(php_source): - """ - Yield dicts for every docblock that contains a @route tag. - Each dict has: method, path, description, body_raw, responses. - responses is a list of (status_code, value) tuples. - """ - blocks = [(m.end(), strip_stars(m.group(1))) for m in DOCBLOCK_RE.finditer(php_source)] - - for end_pos, text in blocks: - route_match = re.search(r'@route\s+(GET|POST|PUT|DELETE|PATCH)\s+(\S+)', text) - if not route_match: - continue - - method = route_match.group(1).lower() - full_path = route_match.group(2) - oapi_path = full_path if full_path else '/' - - desc_raw = text[:text.find('@')].strip() if '@' in text else text.strip() - # Split into blank-line-separated paragraphs; each paragraph is one - # or more consecutive non-empty lines joined into a single string. - paragraphs = [] - current = [] - for ln in desc_raw.splitlines(): - stripped = ln.strip() - if stripped: - current.append(stripped) - elif current: - paragraphs.append(' '.join(current)) - current = [] - if current: - paragraphs.append(' '.join(current)) - - if len(paragraphs) == 0: - summary = None - description = None - elif len(paragraphs) == 1: - summary = None # falls back to route slug in build_openapi - description = paragraphs[0] - else: - summary = paragraphs[0] - description = ' '.join(paragraphs[1:]) - - body_match = re.search(r'@body\s+(.+)', text) - body_raw = body_match.group(1).strip() if body_match else None - - path_param_names = set(extract_path_params(oapi_path)) - - # @pathparam [enum:a,b,c] [example:x] [free-text description] - # Lets a path parameter advertise valid values / a default so the API - # tester (Scalar) sends something usable instead of a placeholder. - path_param_meta = {} - for pm in re.finditer(r'@pathparam\s+(\S+)\s+(.*)', text): - pname = pm.group(1) - rest = pm.group(2).strip() - meta = {} - m_enum = re.search(r'enum:(\S+)', rest) - if m_enum: - meta['enum'] = m_enum.group(1).split(',') - rest = rest.replace(m_enum.group(0), '', 1).strip() - m_ex = re.search(r'example:(\S+)', rest) - if m_ex: - meta['example'] = m_ex.group(1) - rest = rest.replace(m_ex.group(0), '', 1).strip() - if rest: - meta['description'] = rest - path_param_meta[pname] = meta - - params = [] - for pm in re.finditer(r'@param\s+(\S+)\s+(\S+)\s*(.*)', text): - php_type, pname, pdesc = pm.group(1), pm.group(2), pm.group(3).strip() - if pname in path_param_names: - continue # path params are handled via extract_path_params - type_map = {'int': 'integer', 'integer': 'integer', - 'bool': 'boolean', 'boolean': 'boolean', - 'float': 'number', 'number': 'number'} - params.append({ - 'name': pname, - 'in': 'query', - 'required': False, - 'schema': {'type': type_map.get(php_type.lower(), 'string')}, - 'description': pdesc or None, - }) - - responses = [] - for rm in RESPONSE_RE.finditer(text): - status = int(rm.group(1)) if rm.group(1) else 200 - resp_desc = rm.group(2).strip() - media_hint = rm.group(3) # e.g. 'json', 'text', 'bytes' or None - content = rm.group(4).strip() if rm.group(4) is not None else None - responses.append((status, resp_desc, media_hint, content)) - - badges = [] - for bm in re.finditer(r'@badge\s+"([^"]+)"\s+(\w+)', text): - level = bm.group(2).lower() - badges.append({ - 'name': bm.group(1), - 'color': BADGE_COLORS.get(level, BADGE_COLORS['info']), - }) - - yield { - 'method': method, - 'path': oapi_path, - 'summary': summary, - 'description': description, - 'body_raw': body_raw, - 'responses': responses, - 'badges': badges, - 'params': params, - 'path_param_meta': path_param_meta, - } - - -def load_endpoints(): - endpoints = [] - for php_file in SOURCES: - source = open(php_file, encoding='utf-8', errors='replace').read() - for ep in parse_docblocks(source): - endpoints.append(ep) - endpoints = rewrite_v1_compat_routes(endpoints) - endpoints.sort(key=lambda e: (e['path'], e['method'])) - return endpoints - - -def rewrite_v1_compat_routes(endpoints): - rewritten = [] - for ep in endpoints: - path = ep['path'] - if path.startswith('/api/v2/'): - continue - if path in ROUTELESS_DOC_PATHS: - continue - - current = dict(ep) - if current['method'] == 'post' and current['path'] in V1_GET_COMPAT_PATHS: - current['method'] = 'get' - current['body_raw'] = None - - if current['path'].startswith('/api/'): - current['path'] = current['path'].replace('/api/', '/', 1) - rewritten.append(current) - - return rewritten - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def parse_json_value(raw): - try: - return json.loads(raw) - except (json.JSONDecodeError, TypeError): - return raw - - -def extract_mime_override(block_content, default_type): - """ - If the first line of block_content is a bracketed MIME-type hint such as - [Content-Type: text/event-stream] - [Raw Binary Stream: application/zip] - return (mime_type, remaining_content_stripped). - Otherwise return (default_type, block_content). - """ - if not block_content: - return default_type, block_content - lines = block_content.splitlines() - m = re.match(r'^\[.*?([a-z]+/[a-z][a-z0-9+.\-]*)\]', lines[0], re.IGNORECASE) - if m: - rest = '\n'.join(lines[1:]).strip() - return m.group(1), rest - return default_type, block_content - - -def extract_path_params(path): - return re.findall(r'\{([^}]+)\}', path) - - -def tag_from_path(path): - parts = [p for p in path.strip('/').split('/') if p and not p.startswith('{') and p != 'api'] - return parts[0] if parts else 'general' - - -# --------------------------------------------------------------------------- -# Build OpenAPI dict -# --------------------------------------------------------------------------- - -def build_openapi(endpoints): - tags = sorted(set(tag_from_path(e['path']) for e in endpoints)) - - spec = { - 'openapi': '3.0.3', - 'info': { - 'title': 'FPP API', - 'description': 'Falcon Player (FPP) REST API', - 'version': '1.0', - }, - 'servers': [{'url': '/api/', 'description': 'Local FPP instance'}], - 'tags': [{'name': t} for t in tags], - 'paths': {}, - } - - by_path = defaultdict(list) - for ep in endpoints: - by_path[ep['path']].append(ep) - - for path in sorted(by_path): - eps = by_path[path] - params = extract_path_params(path) - path_item = {} - - if params: - # Merge any @pathparam metadata declared on the endpoints for this path. - meta_by_name = {} - for ep in eps: - for name, meta in ep.get('path_param_meta', {}).items(): - meta_by_name.setdefault(name, {}).update(meta) - - path_item['parameters'] = [] - for p in params: - meta = meta_by_name.get(p, {}) - schema = {'type': 'string'} - if 'enum' in meta: - schema['enum'] = meta['enum'] - entry = {'name': p, 'in': 'path', 'required': True, 'schema': schema} - if 'example' in meta: - entry['example'] = meta['example'] - if 'description' in meta: - entry['description'] = meta['description'] - path_item['parameters'].append(entry) - - for ep in sorted(eps, key=lambda e: e['method']): - method = ep['method'] - tag = tag_from_path(path) - route_slug = path.removeprefix(API_PREFIX).lstrip('/') - summary = ep['summary'] if ep['summary'] else route_slug - - operation = { - 'tags': [tag], - 'summary': summary, - } - - if ep['description']: - operation['description'] = ep['description'] - - if ep['badges']: - operation['x-badges'] = ep['badges'] - - if ep['params']: - operation['parameters'] = [ - {k: v for k, v in p.items() if v is not None} - for p in ep['params'] - ] - - if ep['body_raw']: - body_val = parse_json_value(ep['body_raw']) - if isinstance(body_val, str): - content = {'text/plain': {'schema': {'type': 'string'}, 'example': body_val}} - else: - content = { - 'application/json': { - 'schema': {'type': 'array' if isinstance(body_val, list) else 'object'}, - 'example': body_val, - } - } - operation['requestBody'] = {'content': content} - - responses = {} - if ep['responses']: - seen = set() - for status, desc, media_hint, block_content in ep['responses']: - if status in seen: - continue - seen.add(status) - - if media_hint is not None and block_content is not None: - content_type = MEDIA_HINT_MAP.get(media_hint, 'application/octet-stream') - - if media_hint == 'json': - parsed = parse_json_value(block_content) - if isinstance(parsed, str): - schema = {'type': 'string'} - example = parsed - else: - schema = {'type': 'array' if isinstance(parsed, list) else 'object'} - example = parsed - responses[str(status)] = { - 'description': desc, - 'content': {content_type: {'schema': schema, 'example': example}}, - } - elif media_hint == 'bytes': - content_type, _ = extract_mime_override(block_content, content_type) - responses[str(status)] = { - 'description': desc, - 'content': {content_type: {'schema': {'type': 'string', 'format': 'binary'}}}, - } - else: - # text, xml, html, and any unknown hint - # A bracketed first line overrides the content type and is stripped from the example. - content_type, example = extract_mime_override(block_content, content_type) - responses[str(status)] = { - 'description': desc, - 'content': { - content_type: { - 'schema': {'type': 'string'}, - 'example': example, - } - }, - } - else: - # No fenced block — fall back: try to parse desc as inline JSON (legacy) - val = parse_json_value(desc) - if isinstance(val, str): - responses[str(status)] = {'description': val} - else: - fallback_desc = 'Success' if status == 200 else f'HTTP {status}' - responses[str(status)] = { - 'description': fallback_desc, - 'content': { - 'application/json': { - 'schema': {'type': 'array' if isinstance(val, list) else 'object'}, - 'example': val, - } - }, - } - else: - responses['200'] = {'description': 'Success'} - - operation['responses'] = responses - path_item[method] = operation - - spec['paths'][path] = path_item - - return spec - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def main(): - endpoints = load_endpoints() - if not endpoints: - print('ERROR: No @route annotations found. Are you running from www/api/?', file=sys.stderr) - sys.exit(1) - - spec = build_openapi(endpoints) - OUTPUT.write_text(json.dumps(spec, indent=2), encoding='utf-8') - - path_count = len(set(e['path'] for e in endpoints)) - op_count = len(endpoints) - print(f'Written {OUTPUT} ({path_count} paths, {op_count} operations)') - - -if __name__ == '__main__': - main() diff --git a/www/api/tools/generate_openapi_v2.py b/www/api/tools/generate_openapi_v2.py index 8b01b8812..34609ef6f 100644 --- a/www/api/tools/generate_openapi_v2.py +++ b/www/api/tools/generate_openapi_v2.py @@ -1,388 +1,22 @@ #!/usr/bin/env python3 """ -Generate www/api/v2/openapi.json from @route/@body/@response PHPDoc tags +Generate www/api/v2/openapi.json from @route-v2 PHPDoc tags in www/api/controllers/*.php. Usage (run from www/api/): python3 tools/generate_openapi_v2.py - -Docblock summary/description rules: - - One prose line → description only; summary falls back to the route slug. - - Two+ prose lines → first line is summary, remainder joined as description. - -Badge syntax (multiple allowed, Scalar x-badges spec): - @badge "Label text" - Levels: success | warning | critical | info - Maps to {name, color} — Scalar uses color as the badge background. """ -import glob -import json -import re import sys -from collections import defaultdict from pathlib import Path -API_PREFIX = '/api/v2' -CONTROLLERS = sorted(glob.glob(str(Path(__file__).parent.parent / 'controllers' / '*.php'))) -OUTPUT = Path(__file__).parent.parent / 'v2' / 'openapi.json' - -ROUTELESS_DOC_PATHS = { - '/api/help', -} - -BADGE_COLORS = { - 'success': '#2e7d32', - 'warning': '#b25e00', - 'critical': '#c62828', - 'info': '#546e7a', -} +sys.path.insert(0, str(Path(__file__).parent)) +from generate_openapi_base import main # noqa: E402 -MEDIA_HINT_MAP = { - 'json': 'application/json', - 'text': 'text/plain', - 'bytes': 'application/octet-stream', - 'binary': 'application/octet-stream', - 'xml': 'application/xml', - 'html': 'text/html', -} - -# Matches @response, its optional fenced code block, and stops before the next tag. -# Groups: (1) status code, (2) description, (3) media hint, (4) block content -RESPONSE_RE = re.compile( - r'@response(?:\s+(\d{3}))?\s+([^\n]+)' - r'(?:\n\s*```(\w+)\n(.*?)\n\s*```)?', - re.DOTALL, +main( + version=2, + output='v2/openapi.json', + server_url='/api/v2', + server_desc='Local FPP instance (v2)', + info_version='2.0', ) - - -# --------------------------------------------------------------------------- -# PHPDoc parsing -# --------------------------------------------------------------------------- - -DOCBLOCK_RE = re.compile(r'/\*\*(.*?)\*/', re.DOTALL) - - -def strip_stars(block): - lines = [] - for line in block.splitlines(): - line = re.sub(r'^\s*\*\s?', '', line) - lines.append(line) - return '\n'.join(lines) - - -def parse_docblocks(php_source): - """ - Yield dicts for every docblock that contains a @route tag. - Each dict has: method, path, description, body_raw, responses. - responses is a list of (status_code, value) tuples. - """ - blocks = [(m.end(), strip_stars(m.group(1))) for m in DOCBLOCK_RE.finditer(php_source)] - - for end_pos, text in blocks: - route_match = re.search(r'@route\s+(GET|POST|PUT|DELETE|PATCH)\s+(\S+)', text) - if not route_match: - continue - - method = route_match.group(1).lower() - full_path = route_match.group(2) - oapi_path = full_path if full_path else '/' - - desc_raw = text[:text.find('@')].strip() if '@' in text else text.strip() - # Split into blank-line-separated paragraphs; each paragraph is one - # or more consecutive non-empty lines joined into a single string. - paragraphs = [] - current = [] - for ln in desc_raw.splitlines(): - stripped = ln.strip() - if stripped: - current.append(stripped) - elif current: - paragraphs.append(' '.join(current)) - current = [] - if current: - paragraphs.append(' '.join(current)) - - if len(paragraphs) == 0: - summary = None - description = None - elif len(paragraphs) == 1: - summary = None # falls back to route slug in build_openapi - description = paragraphs[0] - else: - summary = paragraphs[0] - description = ' '.join(paragraphs[1:]) - - body_match = re.search(r'@body\s+(.+)', text) - body_raw = body_match.group(1).strip() if body_match else None - - path_param_names = set(extract_path_params(oapi_path)) - params = [] - for pm in re.finditer(r'@param\s+(\S+)\s+(\S+)\s*(.*)', text): - php_type, pname, pdesc = pm.group(1), pm.group(2), pm.group(3).strip() - if pname in path_param_names: - continue # path params are handled via extract_path_params - type_map = {'int': 'integer', 'integer': 'integer', - 'bool': 'boolean', 'boolean': 'boolean', - 'float': 'number', 'number': 'number'} - params.append({ - 'name': pname, - 'in': 'query', - 'required': False, - 'schema': {'type': type_map.get(php_type.lower(), 'string')}, - 'description': pdesc or None, - }) - - responses = [] - for rm in RESPONSE_RE.finditer(text): - status = int(rm.group(1)) if rm.group(1) else 200 - resp_desc = rm.group(2).strip() - media_hint = rm.group(3) # e.g. 'json', 'text', 'bytes' or None - content = rm.group(4).strip() if rm.group(4) is not None else None - responses.append((status, resp_desc, media_hint, content)) - - badges = [] - for bm in re.finditer(r'@badge\s+"([^"]+)"\s+(\w+)', text): - level = bm.group(2).lower() - badges.append({ - 'name': bm.group(1), - 'color': BADGE_COLORS.get(level, BADGE_COLORS['info']), - }) - - yield { - 'method': method, - 'path': oapi_path, - 'summary': summary, - 'description': description, - 'body_raw': body_raw, - 'responses': responses, - 'badges': badges, - 'params': params, - } - - -def load_endpoints(): - endpoints = [] - for php_file in CONTROLLERS: - source = open(php_file, encoding='utf-8', errors='replace').read() - for ep in parse_docblocks(source): - endpoints.append(ep) - endpoints = rewrite_v2_paths(endpoints) - endpoints.sort(key=lambda e: (e['path'], e['method'])) - return endpoints - - -def rewrite_v2_paths(endpoints): - rewritten = [] - for ep in endpoints: - current = dict(ep) - if current['path'] in ROUTELESS_DOC_PATHS: - continue - if current['path'] == '/api/remoteAction' and current['method'] == 'get': - continue - if current['path'].startswith('/api/v2/'): - rewritten.append(current) - continue - if current['path'].startswith('/api/'): - current['path'] = current['path'].replace('/api/', '/api/v2/', 1) - rewritten.append(current) - return rewritten - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def parse_json_value(raw): - try: - return json.loads(raw) - except (json.JSONDecodeError, TypeError): - return raw - - -def extract_mime_override(block_content, default_type): - """ - If the first line of block_content is a bracketed MIME-type hint such as - [Content-Type: text/event-stream] - [Raw Binary Stream: application/zip] - return (mime_type, remaining_content_stripped). - Otherwise return (default_type, block_content). - """ - if not block_content: - return default_type, block_content - lines = block_content.splitlines() - m = re.match(r'^\[.*?([a-z]+/[a-z][a-z0-9+.\-]*)\]', lines[0], re.IGNORECASE) - if m: - rest = '\n'.join(lines[1:]).strip() - return m.group(1), rest - return default_type, block_content - - -def extract_path_params(path): - return re.findall(r'\{([^}]+)\}', path) - - -def tag_from_path(path): - parts = [p for p in path.strip('/').split('/') if p and not p.startswith('{') and p not in ('api', 'v2')] - return parts[0] if parts else 'general' - - -# --------------------------------------------------------------------------- -# Build OpenAPI dict -# --------------------------------------------------------------------------- - -def build_openapi(endpoints): - tags = sorted(set(tag_from_path(e['path']) for e in endpoints)) - - spec = { - 'openapi': '3.0.3', - 'info': { - 'title': 'FPP API', - 'description': 'Falcon Player (FPP) REST API', - 'version': '2.0', - }, - 'servers': [{'url': '/api/v2', 'description': 'Local FPP instance (v2)'}], - 'tags': [{'name': t} for t in tags], - 'paths': {}, - } - - by_path = defaultdict(list) - for ep in endpoints: - by_path[ep['path']].append(ep) - - for path in sorted(by_path): - eps = by_path[path] - params = extract_path_params(path) - path_item = {} - - if params: - path_item['parameters'] = [ - {'name': p, 'in': 'path', 'required': True, 'schema': {'type': 'string'}} - for p in params - ] - - for ep in sorted(eps, key=lambda e: e['method']): - method = ep['method'] - tag = tag_from_path(path) - route_slug = path.removeprefix(API_PREFIX).lstrip('/') - summary = ep['summary'] if ep['summary'] else route_slug - - operation = { - 'tags': [tag], - 'summary': summary, - } - - if ep['description']: - operation['description'] = ep['description'] - - if ep['badges']: - operation['x-badges'] = ep['badges'] - - if ep['params']: - operation['parameters'] = [ - {k: v for k, v in p.items() if v is not None} - for p in ep['params'] - ] - - if ep['body_raw']: - body_val = parse_json_value(ep['body_raw']) - if isinstance(body_val, str): - content = {'text/plain': {'schema': {'type': 'string'}, 'example': body_val}} - else: - content = { - 'application/json': { - 'schema': {'type': 'array' if isinstance(body_val, list) else 'object'}, - 'example': body_val, - } - } - operation['requestBody'] = {'content': content} - - responses = {} - if ep['responses']: - seen = set() - for status, desc, media_hint, block_content in ep['responses']: - if status in seen: - continue - seen.add(status) - - if media_hint is not None and block_content is not None: - content_type = MEDIA_HINT_MAP.get(media_hint, 'application/octet-stream') - - if media_hint == 'json': - parsed = parse_json_value(block_content) - if isinstance(parsed, str): - schema = {'type': 'string'} - example = parsed - else: - schema = {'type': 'array' if isinstance(parsed, list) else 'object'} - example = parsed - responses[str(status)] = { - 'description': desc, - 'content': {content_type: {'schema': schema, 'example': example}}, - } - elif media_hint == 'bytes': - content_type, _ = extract_mime_override(block_content, content_type) - responses[str(status)] = { - 'description': desc, - 'content': {content_type: {'schema': {'type': 'string', 'format': 'binary'}}}, - } - else: - # text, xml, html, and any unknown hint - # A bracketed first line overrides the content type and is stripped from the example. - content_type, example = extract_mime_override(block_content, content_type) - responses[str(status)] = { - 'description': desc, - 'content': { - content_type: { - 'schema': {'type': 'string'}, - 'example': example, - } - }, - } - else: - # No fenced block — fall back: try to parse desc as inline JSON (legacy) - val = parse_json_value(desc) - if isinstance(val, str): - responses[str(status)] = {'description': val} - else: - fallback_desc = 'Success' if status == 200 else f'HTTP {status}' - responses[str(status)] = { - 'description': fallback_desc, - 'content': { - 'application/json': { - 'schema': {'type': 'array' if isinstance(val, list) else 'object'}, - 'example': val, - } - }, - } - else: - responses['200'] = {'description': 'Success'} - - operation['responses'] = responses - path_item[method] = operation - - spec['paths'][path.removeprefix(API_PREFIX) or '/'] = path_item - - return spec - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def main(): - endpoints = load_endpoints() - if not endpoints: - print('ERROR: No @route annotations found. Are you running from www/api/?', file=sys.stderr) - sys.exit(1) - - spec = build_openapi(endpoints) - OUTPUT.write_text(json.dumps(spec, indent=2), encoding='utf-8') - - path_count = len(set(e['path'] for e in endpoints)) - op_count = len(endpoints) - print(f'Written {OUTPUT} ({path_count} paths, {op_count} operations)') - - -if __name__ == '__main__': - main() diff --git a/www/api/v1/openapi.json b/www/api/v1/openapi.json index 12836dbc8..c6c117fce 100644 --- a/www/api/v1/openapi.json +++ b/www/api/v1/openapi.json @@ -21,15 +21,6 @@ { "name": "channel" }, - { - "name": "command" - }, - { - "name": "commandPresets" - }, - { - "name": "commands" - }, { "name": "configfile" }, @@ -51,39 +42,21 @@ { "name": "files" }, - { - "name": "fppd" - }, - { - "name": "geoip" - }, { "name": "git" }, - { - "name": "gpio" - }, { "name": "media" }, - { - "name": "models" - }, { "name": "network" }, { "name": "options" }, - { - "name": "overlays" - }, { "name": "pipewire" }, - { - "name": "player" - }, { "name": "playlist" }, @@ -99,9 +72,6 @@ { "name": "proxy" }, - { - "name": "recurringtasks" - }, { "name": "remoteAction" }, @@ -131,9 +101,6 @@ }, { "name": "time" - }, - { - "name": "variables" } ], "paths": { @@ -1255,186 +1222,6 @@ } } }, - "/command": { - "post": { - "tags": [ - "command" - ], - "summary": "command", - "description": "Run a command described by the posted JSON object (with `command` and `args`).", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "command": "Volume Set", - "args": [ - "50" - ] - } - } - } - }, - "responses": { - "200": { - "description": "Command result." - }, - "500": { - "description": "The command errored or timed out." - } - } - } - }, - "/command/{command}": { - "parameters": [ - { - "name": "command", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "command" - ], - "summary": "command/{command}", - "description": "Run a command by name via GET, passing arguments as extra path segments (e.g. /api/command/Volume%20Set/50).", - "responses": { - "200": { - "description": "Command result (text/plain)." - }, - "404": { - "description": "No command with that name exists." - }, - "500": { - "description": "The command errored or timed out." - } - } - }, - "post": { - "tags": [ - "command" - ], - "summary": "command/{command}", - "description": "Run a named command, passing its arguments as a JSON array in the body.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "array" - }, - "example": [ - "arg1", - "arg2" - ] - } - } - }, - "responses": { - "200": { - "description": "Command result." - }, - "500": { - "description": "The command errored or timed out." - } - } - } - }, - "/commandPresets": { - "get": { - "tags": [ - "commandPresets" - ], - "summary": "commandPresets", - "description": "Get the saved command presets (config/commandPresets.json).", - "parameters": [ - { - "name": "names", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "Return just the preset names instead of full definitions." - } - ], - "responses": { - "200": { - "description": "Command presets (or preset names when `names=true`)." - } - } - } - }, - "/commandPresets/{name}": { - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "commandPresets" - ], - "summary": "commandPresets/{name}", - "description": "Get a single command preset by name.", - "responses": { - "200": { - "description": "The preset definition." - } - } - } - }, - "/commands": { - "get": { - "tags": [ - "commands" - ], - "summary": "commands", - "description": "List all available commands and their argument descriptions. Each entry also carries \"category\" (e.g. \"Playlist\", \"Media\", \"Plugins\") and \"level\" (0 Basic / 1 Advanced / 3 Developer) for UI grouping and filtering, plus \"disallowMultisync\" (true) on a command whose Multisync option should stay hidden - e.g. \"If\", since multisyncing it would broadcast the raw check to other instances rather than propagate the result of evaluating it.", - "responses": { - "200": { - "description": "Array of command descriptions." - } - } - } - }, - "/commands/{command}": { - "parameters": [ - { - "name": "command", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "commands" - ], - "summary": "commands/{command}", - "description": "Get the description of a single command by name. Includes the same \"category\"/\"level\" fields as the list route.", - "responses": { - "200": { - "description": "The command description." - }, - "404": { - "description": "No command with that name exists." - } - } - } - }, "/configfile": { "get": { "tags": [ @@ -1792,6 +1579,12 @@ ], "summary": "Trigger event", "description": "Triggers the specified event by sending a `Trigger Event` command to `fppd`.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { "description": "Event triggered", @@ -1866,6 +1659,12 @@ ], "summary": "Move file", "description": "Moves the specified file from the `uploads` directory to the correct media subfolder based on its extension, returning a status of `OK` or an error message if not successful.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { "description": "File moved to media directory", @@ -1900,6 +1699,12 @@ ], "summary": "Notify plugin of upload", "description": "Notifies any plugin that has registered an `onUpload` handler for the given file extension. `:ext` is the extension category and `**` is the file path.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { "description": "Plugin notified of upload", @@ -2195,13 +2000,13 @@ } }, "403": { - "description": "Forbidden directory", + "description": "Forbidden filename", "content": { "text/plain": { "schema": { "type": "string" }, - "example": "Tail follow is only allowed for log files." + "example": "Invalid file path." } } }, @@ -2283,31 +2088,6 @@ } } }, - "/files/Sequences/fps": { - "get": { - "tags": [ - "files" - ], - "summary": "Get sequence frame rates (fps)", - "description": "Returns a map of sequence filename => fps for every `.fseq` file in the sequence directory. This is intentionally split out from the main file listing so the file manager can render the sequence list immediately and lazily fill in the FPS column via this endpoint. The fps is derived from the fseq header StepTime (fps = round(1000 / StepTime)) and cached per file (keyed on name + size) so fsequtils is only run on cache misses.", - "responses": { - "200": { - "description": "Map of sequence filename to fps", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "GreatestShow.fseq": 40, - "subdir/Intro.fseq": 20 - } - } - } - } - } - } - }, "/files/zip/{DirNames}": { "parameters": [ { @@ -2394,111 +2174,74 @@ } } }, - "/fppd/condition/preview": { - "get": { - "tags": [ - "fppd" - ], - "summary": "Preview a single If/Conditional Check leaf - the same lookup evaluate() uses internally (Variable/Expression/Time/GPIO Pin/Sun), before any comparator/Value is applied. Backs the If condition editor's \"Show Current Value\" button, so picking the right Value to compare against isn't guesswork.", - "description": "When a \"comparator\" param is also supplied (the consolidated eye-preview modal's full-leaf mode), also evaluates Value the same way Value is unconditionally evaluated at runtime and applies the comparator, returning the RHS value and the boolean result too - reuses ConditionNode::PreviewLeafResult(), the exact same evaluation path a real saved leaf's evaluate() takes, so this can never drift from runtime behavior.", - "parameters": [ - { - "name": "source", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "One of the If Check \"Source\" dropdown values (e.g. \"Variable\", \"GPIO Pin\")." - }, - { - "name": "name", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "The Name/Expression field for that source." - }, - { - "name": "comparator", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Optional - one of the Check \"Comparator\" values. Enables full-leaf mode." - }, - { - "name": "value", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Optional - the Value field, only used when \"comparator\" is also given." - }, - { - "name": "not", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Optional - \"true\" to negate the result, only used when \"comparator\" is also given." - } - ], - "responses": { - "200": { - "description": "{\"found\": true, \"value\": \"...\"} (source/name-only mode) or" - } - } - } - }, - "/fppd/e131stats": { - "delete": { - "tags": [ - "fppd" - ], - "summary": "fppd/e131stats", - "description": "Reset the E1.31/sACN receive statistics.", - "responses": { - "200": { - "description": "Statistics cleared." - } - } - }, + "/git/branches": { "get": { "tags": [ - "fppd" + "git" ], - "summary": "fppd/e131stats", - "description": "Get the number of E1.31/sACN bytes received per universe.", + "summary": "Get local branches", + "description": "Returns an array of branches available to switch to, filtering out obsolete version branches and Dependabot branches.", "responses": { "200": { - "description": "E1.31 receive statistics." + "description": "Available local branches", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "master", + "v7.3", + "v7.2", + "v7.1", + "v7.0" + ] + } + } } } } }, - "/fppd/effects": { + "/git/originLog": { "get": { "tags": [ - "fppd" + "git" ], - "summary": "fppd/effects", - "description": "List the effects currently running on the player.", + "summary": "Get origin commits", + "description": "Returns a list of commits present in the `origin` (GitHub) but not in the local repository.", "responses": { "200": { - "description": "Object with a `runningEffects` array." + "description": "Commits in origin not yet in local", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "rows": [ + { + "hash": "95ccb370e45272d8aed76aabfa55e60d489a8280", + "author": "GithubUser1", + "msg": "Use our SaveJsonToString() when generating MQTT warnings JSON message." + }, + { + "hash": "2fad5ad941baea49edaab834429343b42981bcc5", + "author": "GithubUser2", + "msg": "Move Playlist initialization into main() via Player::Init()" + } + ] + } + } + } } } } }, - "/fppd/effects/{name}": { + "/git/releases/os/{All}": { "parameters": [ { - "name": "name", + "name": "All", "in": "path", "required": true, "schema": { @@ -2506,1445 +2249,277 @@ } } ], - "post": { + "get": { "tags": [ - "fppd" + "git" ], - "summary": "fppd/effects/{name}", - "description": "Start (or update) a named overlay effect on the player.", + "summary": "Get releases for OS", + "description": "Returns lists of `.fppos` files available locally or on GitHub for the current platform. If the `{All}` path parameter is `\"all\"`, returns all releases regardless of platform.", "responses": { "200": { - "description": "Effect started." + "description": "Available OS release assets", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "downloaded": [ + "Pi-v4.4.fppos", + "Pi-5.0-alpha1.fppos" + ], + "files": [ + { + "tag": "5.1", + "release_name": "5.1", + "filename": "Pi-5.1.1.fppos", + "url": "https://github.com/FalconChristmas/fpp/releases/download/5.1/Pi-5.1.1.fppos", + "asset_id": 42917234, + "downloaded": false, + "size": 0, + "prerelease": false + } + ] + } + } + } } } } }, - "/fppd/falcon/hardware": { - "post": { + "/git/releases/sizes": { + "get": { "tags": [ - "fppd" + "git" ], - "summary": "fppd/falcon/hardware", - "description": "Re-read the Falcon hardware (e.g. cape/receiver) configuration.", + "summary": "Get release asset sizes", + "description": "Returns release asset size information from the GitHub `FalconChristmas/fpp` releases API.", "responses": { "200": { - "description": "Hardware refreshed." + "description": "Release asset sizes", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "BBB-nightly_2026-05.fppos,1161494528", + "BBB-10.0-alpha_2026-02.fppos,884658176" + ] + } + } } } } }, - "/fppd/gpio/ext": { - "post": { + "/git/reset": { + "get": { "tags": [ - "fppd" + "git" + ], + "summary": "git/reset", + "description": "Discard local changes Performs a hard reset on the current branch, discarding any local changes.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } ], - "summary": "fppd/gpio/ext", - "description": "Set an external GPIO input state.", "responses": { "200": { - "description": "GPIO state updated." + "description": "Reset complete", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "log": [ + "HEAD is now at a1b65d43 Git Reset moved - #944", + "Entering 'external/RF24'", + "HEAD is now at ebc3abe Fix typo, missing space." + ] + } + } + } } } } }, - "/fppd/log": { + "/git/status": { "get": { "tags": [ - "fppd" + "git" ], - "summary": "fppd/log", - "description": "Get the current fppd logging configuration (log level and enabled channels).", + "summary": "Get local repo status", + "description": "Returns the status of the local git branch, including any dirty files.", "responses": { "200": { - "description": "Current log settings." + "description": "Local repository status", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "log": "On branch master\nYour branch is up to date with 'origin/master'." + } + } + } } } } }, - "/fppd/log/level/{level}": { - "parameters": [ - { - "name": "level", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "error", - "warn", - "info", - "debug", - "excess" - ] - }, - "example": "debug", - "description": "Log level to apply globally, or a level:channel[,channel] targeting expression." - } - ], - "post": { + "/media": { + "get": { "tags": [ - "fppd" + "media" ], - "summary": "Set logging levels.", - "description": "There is no separate channel parameter: the {level} path segment carries both the level and the optional channel targeting. A bare level name - one of error, warn, info, debug, or excess - sets that level globally for every log channel. To target specific channels instead, use level:channel,channel and separate multiple groups with a semicolon, e.g. debug:Schedule,Player;info:Sync. Channel names are case-sensitive and match those returned by GET /api/fppd/log (for example Command, Control, HTTP, Schedule, Sync).", + "summary": "List all media files", + "description": "Returns a list of media files (includes both music and video files).", "responses": { "200": { - "description": "Log level updated." - }, - "400": { - "description": "Invalid or unrecognized log level." + "description": "List of media filenames", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "Frosty.mp4", + "Jingle_Bells.mp3" + ] + } + } } } } }, - "/fppd/mqtt/cache": { + "/media/{MediaName}/duration": { + "parameters": [ + { + "name": "MediaName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "get": { "tags": [ - "fppd" + "media" ], - "summary": "fppd/mqtt/cache", - "description": "Dump the cached MQTT messages.", + "summary": "Get duration of media item", + "description": "Returns the duration of a media item.", "responses": { "200": { - "description": "Object keyed by topic, each value the topic's last cached message as a plain string." + "description": "Media duration", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "1min_720p29_2014-10-01.mp4": { + "duration": 60.010666666667 + } + } + } + } }, - "400": { - "description": "MQTT is not initialized." + "404": { + "description": "Media file not found", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Not found: {MediaName}" + } + } } } } }, - "/fppd/multiSyncStats": { + "/media/{MediaName}/meta": { + "parameters": [ + { + "name": "MediaName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "get": { "tags": [ - "fppd" - ], - "summary": "fppd/multiSyncStats", - "description": "Get MultiSync packet statistics.", - "parameters": [ - { - "name": "reset", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Set to 1 to reset the statistics after reading them." - } + "media" ], + "summary": "Get metadata for media item", + "description": "Returns metadata streams, codecs, profiles, type for a specific media file.", "responses": { "200": { - "description": "MultiSync statistics." + "description": "Media file metadata", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "programs": [], + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_time_base": "500/29971" + } + ] + } + } + } } } } }, - "/fppd/multiSyncSystems": { + "/network/dns": { "get": { "tags": [ - "fppd" - ], - "summary": "fppd/multiSyncSystems", - "description": "List the MultiSync systems discovered on the network.", - "parameters": [ - { - "name": "localOnly", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Set to 1 to return only the local system." - } + "network" ], + "summary": "Get DNS configuration", + "description": "Returns the current DNS configuration. If not configured, `status` will be `Not Configured`.", "responses": { "200": { - "description": "MultiSync systems." + "description": "Current DNS configuration", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "DNS1": "192.168.50.1", + "DNS2": "192.168.1.1", + "status": "OK" + } + } + } } } - } - }, - "/fppd/outputs": { - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/outputs", - "description": "Apply a new channel-output configuration.", - "responses": { - "200": { - "description": "Outputs updated." - } - } - } - }, - "/fppd/outputs/remap": { - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/outputs/remap", - "description": "Remap channel outputs.", - "responses": { - "200": { - "description": "Outputs remapped." - } - } - } - }, - "/fppd/playlist/config": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/playlist/config", - "description": "Get the configuration of the running playlist.", - "responses": { - "200": { - "description": "Playlist configuration." - } - } - } - }, - "/fppd/playlist/filetime": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/playlist/filetime", - "description": "Get the last-modified time of the running playlist file.", - "responses": { - "200": { - "description": "Playlist file time." - } - } - } - }, - "/fppd/playlists": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/playlists", - "description": "List the playlists that are currently running.", - "responses": { - "200": { - "description": "Currently running playlists." - } - } - } - }, - "/fppd/ports": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/ports", - "description": "Get the current status of every output port (current draw, smart-receiver data, sensor readings, etc.).", - "responses": { - "200": { - "description": "Array of port status objects." - } - } - } - }, - "/fppd/ports/list": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/ports/list", - "description": "List the names of all configured output ports.", - "responses": { - "200": { - "description": "Array of port names (first entry is \"--ALL--\").", - "content": { - "application/json": { - "schema": { - "type": "array" - }, - "example": [ - "--ALL--", - "Port 1", - "Port 2" - ] - } - } - } - } - } - }, - "/fppd/ports/pixelCount": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/ports/pixelCount", - "description": "Start a pixel-count test on all ports, then return current port status.", - "responses": { - "200": { - "description": "Array of port status objects." - } - } - } - }, - "/fppd/ports/stop": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/ports/stop", - "description": "Stop any running port test, then return current port status.", - "responses": { - "200": { - "description": "Array of port status objects." - } - } - } - }, - "/fppd/schedule": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/schedule", - "description": "Get the currently loaded schedule.", - "responses": { - "200": { - "description": "Object with a `schedule` member." - } - } - }, - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/schedule", - "description": "Replace the active schedule.", - "responses": { - "200": { - "description": "Schedule updated." - } - } - } - }, - "/fppd/schedule/range": { - "get": { - "tags": [ - "fppd" - ], - "summary": "Expand the schedule over an arbitrary date range.", - "description": "Unlike `/api/fppd/schedule`, which reports only the rolling window fppd has actually scheduled out, this expands the configured schedule rules across any requested range so past and future dates can be previewed. Occurrences are advisory - they describe what the schedule says should happen, not what fppd has committed to running.", - "parameters": [ - { - "name": "start", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Range start as epoch seconds." - }, - { - "name": "end", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Range end as epoch seconds. Must be after `start` and no more than 420 days later." - }, - { - "name": "includeDisabled", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "Set to `1` to also expand disabled schedule entries." - }, - { - "name": "summary", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "Set to `1` to collapse to one item per schedule entry per day, each carrying a `count` of that day's occurrences. Intended for month and other coarse views, where a repeating entry would otherwise return thousands of items." - } - ], - "responses": { - "200": { - "description": "Object with a `schedule` member containing `entries`, `items`, `rangeStart` and `rangeEnd`." - }, - "400": { - "description": "Missing, malformed, or excessively large range." - } - } - } - }, - "/fppd/sequence": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/sequence", - "description": "Get the list of running sequences.", - "responses": { - "200": { - "description": "Running sequences." - } - } - } - }, - "/fppd/shutdown": { - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/shutdown", - "description": "Shut down the fppd daemon.", - "responses": { - "200": { - "description": "fppd shutting down." - } - } - } - }, - "/fppd/status": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/status", - "description": "Get the full current player status (playlist, sequence, time, mode, etc.).", - "responses": { - "200": { - "description": "Current player status JSON." - } - } - } - }, - "/fppd/testing": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/testing", - "description": "Get the current test-mode configuration.", - "responses": { - "200": { - "description": "Object with the current test `config`." - } - } - }, - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/testing", - "description": "Activate or deactivate test mode. POST the test configuration JSON (with an `enabled` flag); an empty/disabled config turns test mode off.", - "responses": { - "200": { - "description": "Test mode activated or deactivated." - } - } - } - }, - "/fppd/testing/tests": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/testing/tests", - "description": "List the available test pattern names.", - "responses": { - "200": { - "description": "Array of test pattern names.", - "content": { - "application/json": { - "schema": { - "type": "array" - }, - "example": [ - "RGB Chase", - "RGB Cycle", - "Custom Chase", - "Custom Cycle", - "RGB Single Color", - "Single Channel Chase", - "Single Channel Fill", - "Output Specific" - ] - } - } - } - } - } - }, - "/fppd/testing/tests/{pattern}": { - "parameters": [ - { - "name": "pattern", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/testing/tests/{pattern}", - "description": "Get the argument definitions for a specific test pattern.", - "responses": { - "200": { - "description": "Object with an `args` array describing the pattern's inputs." - }, - "400": { - "description": "The named test pattern does not exist." - } - } - } - }, - "/fppd/version": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/version", - "description": "Get FPP version information.", - "responses": { - "200": { - "description": "Version details.", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "version": "9.0", - "majorVersion": 9, - "minorVersion": 0, - "branch": "master", - "fppdAPI": 4, - "Status": "OK" - } - } - } - } - } - } - }, - "/fppd/volume": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/volume", - "description": "Get (or set) the master output volume.", - "parameters": [ - { - "name": "set", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "New volume (0-100) to apply before returning." - }, - { - "name": "simple", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "Return the volume as a bare text/plain integer instead of JSON." - } - ], - "responses": { - "200": { - "description": "Object with a `volume` member (0-100)." - } - } - } - }, - "/fppd/volume/{volume}": { - "parameters": [ - { - "name": "volume", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/volume/{volume}", - "description": "Set the master output volume (0-100).", - "responses": { - "200": { - "description": "Object with the new `volume`." - } - } - } - }, - "/fppd/warnings": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/warnings", - "description": "List the messages for all currently active warnings.", - "responses": { - "200": { - "description": "Array of warning message strings.", - "content": { - "application/json": { - "schema": { - "type": "array" - }, - "example": [ - "Low disk space", - "No network connection" - ] - } - } - } - } - } - }, - "/fppd/warnings_full": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/warnings_full", - "description": "List all currently active warnings as full objects (message plus metadata).", - "responses": { - "200": { - "description": "Array of warning objects." - } - } - } - }, - "/geoip": { - "get": { - "tags": [ - "geoip" - ], - "summary": "GeoIP lookup", - "description": "Server-side proxy for ipapi.co's IP geolocation lookup, used by the Timezone/GeoLocation \"Lookup\"/\"Detect\" buttons on settings.php. ipapi.co does not send Access-Control-Allow-Origin, so the browser can't call it directly from FPP's UI (blocked by the Same Origin Policy) - PHP isn't subject to that, so we fetch it here and hand back the same JSON.", - "responses": { - "200": { - "description": "ipapi.co's JSON response, passed through unmodified", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "ip": "1.2.3.4", - "city": "Adelaide", - "region": "South Australia", - "timezone": "Australia/Adelaide", - "latitude": -34.9, - "longitude": 138.6 - } - } - } - }, - "502": { - "description": "Lookup failed", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "error": "GeoIP lookup failed" - } - } - } - } - } - } - }, - "/git/branches": { - "get": { - "tags": [ - "git" - ], - "summary": "Get local branches", - "description": "Returns an array of branches available to switch to, filtering out obsolete version branches and Dependabot branches.", - "responses": { - "200": { - "description": "Available local branches", - "content": { - "application/json": { - "schema": { - "type": "array" - }, - "example": [ - "master", - "v7.3", - "v7.2", - "v7.1", - "v7.0" - ] - } - } - } - } - } - }, - "/git/originLog": { - "get": { - "tags": [ - "git" - ], - "summary": "Get origin commits", - "description": "Returns a list of commits present in the `origin` (GitHub) but not in the local repository.", - "responses": { - "200": { - "description": "Commits in origin not yet in local", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK", - "rows": [ - { - "hash": "95ccb370e45272d8aed76aabfa55e60d489a8280", - "author": "GithubUser1", - "msg": "Use our SaveJsonToString() when generating MQTT warnings JSON message." - }, - { - "hash": "2fad5ad941baea49edaab834429343b42981bcc5", - "author": "GithubUser2", - "msg": "Move Playlist initialization into main() via Player::Init()" - } - ] - } - } - } - } - } - } - }, - "/git/releases/notes/{tag}": { - "parameters": [ - { - "name": "tag", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "git" - ], - "summary": "Get OS release notes for a tag", - "description": "Proxies the GitHub `FalconChristmas/fpp` release-by-tag API so the browser does not call api.github.com directly (same pattern/UA/timeout as GitOSReleases). Returns the raw GitHub release object on success; a non-200 status otherwise so the caller's error handler fires.", - "responses": { - "200": { - "description": "GitHub release object for the tag" - }, - "400": { - "description": "Invalid tag" - }, - "404": { - "description": "Release not found for the tag" - } - } - } - }, - "/git/releases/os/{All}": { - "parameters": [ - { - "name": "All", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "git" - ], - "summary": "Get releases for OS", - "description": "Returns lists of `.fppos` files available locally or on GitHub for the current platform. If the `{All}` path parameter is `\"all\"`, returns all releases regardless of platform.", - "responses": { - "200": { - "description": "Available OS release assets", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK", - "downloaded": [ - "Pi-v4.4.fppos", - "Pi-5.0-alpha1.fppos" - ], - "files": [ - { - "tag": "5.1", - "release_name": "5.1", - "filename": "Pi-5.1.1.fppos", - "url": "https://github.com/FalconChristmas/fpp/releases/download/5.1/Pi-5.1.1.fppos", - "asset_id": 42917234, - "downloaded": false, - "size": 0, - "prerelease": false - } - ] - } - } - } - } - } - } - }, - "/git/releases/sizes": { - "get": { - "tags": [ - "git" - ], - "summary": "Get release asset sizes", - "description": "Returns release asset size information from the GitHub `FalconChristmas/fpp` releases API.", - "responses": { - "200": { - "description": "Release asset sizes", - "content": { - "application/json": { - "schema": { - "type": "array" - }, - "example": [ - "BBB-nightly_2026-05.fppos,1161494528", - "BBB-10.0-alpha_2026-02.fppos,884658176" - ] - } - } - } - } - } - }, - "/git/reset": { - "get": { - "tags": [ - "git" - ], - "summary": "git/reset", - "description": "Discard local changes Performs a hard reset on the current branch, discarding any local changes.", - "responses": { - "200": { - "description": "Reset complete", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK", - "log": [ - "HEAD is now at a1b65d43 Git Reset moved - #944", - "Entering 'external/RF24'", - "HEAD is now at ebc3abe Fix typo, missing space." - ] - } - } - } - } - } - } - }, - "/git/status": { - "get": { - "tags": [ - "git" - ], - "summary": "Get local repo status", - "description": "Returns the status of the local git branch, including any dirty files.", - "responses": { - "200": { - "description": "Local repository status", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK", - "log": "On branch master\nYour branch is up to date with 'origin/master'." - } - } - } - } - } - } - }, - "/gpio": { - "get": { - "tags": [ - "gpio" - ], - "summary": "gpio", - "description": "List the available GPIO pins. Add ?list=true for just the pin names.", - "parameters": [ - { - "name": "list", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "Return just the pin names instead of full capabilities." - } - ], - "responses": { - "200": { - "description": "Array of pin capability objects (or pin names when `list=true`)." - } - } - } - }, - "/gpio/{pin}": { - "parameters": [ - { - "name": "pin", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "gpio" - ], - "summary": "gpio/{pin}", - "description": "Read the last value set on a GPIO pin via the API/commands. Only pins with a cached value can be read; output pins must be SET before they can be read.", - "responses": { - "200": { - "description": "Object with `pin` and `value` (0 or 1)." - }, - "400": { - "description": "The pin has no cached value." - } - } - }, - "post": { - "tags": [ - "gpio" - ], - "summary": "gpio/{pin}", - "description": "Configure a GPIO pin for output and set its value. Body: `{\"value\": 0|1}`.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "value": 1 - } - } - } - }, - "responses": { - "200": { - "description": "Object with `pin` and the applied `value`." - }, - "400": { - "description": "Missing/invalid `value` field." - }, - "404": { - "description": "The named pin does not exist." - }, - "500": { - "description": "Error setting the pin." - } - } - } - }, - "/media": { - "get": { - "tags": [ - "media" - ], - "summary": "List all media files", - "description": "Returns a list of media files (includes both music and video files).", - "responses": { - "200": { - "description": "List of media filenames", - "content": { - "application/json": { - "schema": { - "type": "array" - }, - "example": [ - "Frosty.mp4", - "Jingle_Bells.mp3" - ] - } - } - } - } - } - }, - "/media/{MediaName}/duration": { - "parameters": [ - { - "name": "MediaName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "media" - ], - "summary": "Get duration of media item", - "description": "Returns the duration of a media item.", - "responses": { - "200": { - "description": "Media duration", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "1min_720p29_2014-10-01.mp4": { - "duration": 60.010666666667 - } - } - } - } - }, - "404": { - "description": "Media file not found", - "content": { - "text/plain": { - "schema": { - "type": "string" - }, - "example": "Not found: {MediaName}" - } - } - } - } - } - }, - "/media/{MediaName}/meta": { - "parameters": [ - { - "name": "MediaName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "media" - ], - "summary": "Get metadata for media item", - "description": "Returns metadata streams, codecs, profiles, type for a specific media file.", - "responses": { - "200": { - "description": "Media file metadata", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "programs": [], - "streams": [ - { - "index": 0, - "codec_name": "h264", - "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", - "profile": "High", - "codec_type": "video", - "codec_time_base": "500/29971" - } - ] - } - } - } - } - } - } - }, - "/models": { - "get": { - "tags": [ - "models" - ], - "summary": "models", - "description": "List all configured pixel-overlay models.", - "parameters": [ - { - "name": "simple", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "Return just the model names instead of full definitions." - }, - { - "name": "all", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "Prepend the \"--All Models--\" entry to the result." - } - ], - "responses": { - "200": { - "description": "Array of models (or model names when `simple=true`)." - } - } - }, - "post": { - "tags": [ - "models" - ], - "summary": "models", - "description": "Replace the overlay model definitions (writes config/model-overlays.json) and flag fppd for restart.", - "responses": { - "200": { - "description": "Model overlay configuration saved." - } - } - } - }, - "/models/raw": { - "post": { - "tags": [ - "models" - ], - "summary": "models/raw", - "description": "Upload a raw channel-memory-map file (writes media/channelmemorymaps) and flag fppd for restart.", - "responses": { - "200": { - "description": "Raw channel memory map saved." - } - } - } - }, - "/models/{model}": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "models" - ], - "summary": "models/{model}", - "description": "Get a single pixel-overlay model definition by name.", - "responses": { - "200": { - "description": "The model definition." - }, - "404": { - "description": "No model with that name exists." - } - } - } - }, - "/network/dns": { - "get": { - "tags": [ - "network" - ], - "summary": "Get DNS configuration", - "description": "Returns the current DNS configuration. If not configured, `status` will be `Not Configured`.", - "responses": { - "200": { - "description": "Current DNS configuration", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "DNS1": "192.168.50.1", - "DNS2": "192.168.1.1", - "status": "OK" - } - } - } - } - } - }, - "post": { - "tags": [ - "network" - ], - "summary": "Set DNS configuration", - "description": "Updates the DNS configuration.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "DNS1": "192.168.50.1", - "DNS2": "192.168.1.1" - } - } - } - }, - "responses": { - "200": { - "description": "DNS configuration updated", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK", - "DNS": { - "DNS1": "192.168.50.1", - "DNS2": "192.168.1.1" - } - } - } - } - } - } - } - }, - "/network/gateway": { - "get": { - "tags": [ - "network" - ], - "summary": "Get default gateway", - "description": "Returns the currently configured default gateway IP address. May be empty when using DHCP.", - "responses": { - "200": { - "description": "Current default gateway", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "GATEWAY": "192.168.1.1" - } - } - } - } - } - }, - "post": { - "tags": [ - "network" - ], - "summary": "Set default gateway", - "description": "Saves the default gateway IP address to the `gateway` configuration file.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "GATEWAY": "192.168.1.1" - } - } - } - }, - "responses": { - "200": { - "description": "Default gateway saved", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK", - "GATEWAY": "192.168.1.1" - } - } - } - } - } - } - }, - "/network/interface": { - "get": { - "tags": [ - "network" - ], - "summary": "Get network interface details", - "description": "Returns detailed information about network interfaces, their IP addresses, and Wi-Fi signal strength.", - "responses": { - "200": { - "description": "Network interface details", - "content": { - "application/json": { - "schema": { - "type": "array" - }, - "example": [ - { - "ifindex": 2, - "ifname": "wlan0", - "flags": [ - "BROADCAST", - "MULTICAST", - "UP", - "LOWER_UP" - ], - "mtu": 1500, - "operstate": "UP", - "addr_info": [ - { - "family": "inet", - "local": "192.168.50.146", - "prefixlen": 24 - }, - { - "family": "inet6", - "local": "2001:db8::146", - "prefixlen": 64 - } - ], - "wifi": { - "interface": "wlan0", - "link": 52, - "level": -58, - "noise": -256, - "desc": "good" - } - } - ] - } - } - } - } - } - }, - "/network/interface/add/{interface}": { - "parameters": [ - { - "name": "interface", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "network" - ], - "summary": "Create DHCP interface", - "description": "Creates a new blank DHCP interface configuration file for the specified network interface (e.g. `eth1`, `wlan0`).", - "responses": { - "200": { - "description": "DHCP interface created", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "New Blank Interface created" - } - } - } - } - } - } - }, - "/network/interface/{interface}": { - "parameters": [ - { - "name": "interface", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "network" - ], - "summary": "Get network interface configuration", - "description": "Retrieves the current network interface configuration.", - "responses": { - "200": { - "description": "Network interface configuration", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "INTERFACE": "eth0", - "PROTO": "static", - "ADDRESS": "192.168.1.149", - "NETMASK": "255.255.255.0", - "status": "OK", - "CurrentAddress": "192.168.1.149", - "CurrentNetmask": "255.255.255.0" - } - } - } - } - } - }, + }, "post": { "tags": [ "network" ], - "summary": "Set network interface configuration", - "description": "Updates the saved configuration for the specified `{interface}` but does not restart the network.", + "summary": "Set DNS configuration", + "description": "Updates the DNS configuration.", "requestBody": { "content": { "application/json": { @@ -3952,52 +2527,15 @@ "type": "object" }, "example": { - "INTERFACE": "eth0", - "PROTO": "static", - "ADDRESS": "192.168.1.149", - "NETMASK": "255.255.255.0", - "GATEWAY": "192.168.1.1" + "DNS1": "192.168.50.1", + "DNS2": "192.168.1.1" } } } }, "responses": { "200": { - "description": "Interface configuration saved", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK" - } - } - } - } - } - } - }, - "/network/interface/{interface}/apply": { - "parameters": [ - { - "name": "interface", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "post": { - "tags": [ - "network" - ], - "summary": "Set networking configuration", - "description": "Applies the networking settings for the specified `{interface}` at the OS level and restarts the interface.", - "responses": { - "200": { - "description": "Networking configuration applied", + "description": "DNS configuration updated", "content": { "application/json": { "schema": { @@ -4005,7 +2543,10 @@ }, "example": { "status": "OK", - "output": [] + "DNS": { + "DNS1": "192.168.50.1", + "DNS2": "192.168.1.1" + } } } } @@ -4013,23 +2554,23 @@ } } }, - "/network/persistentNames": { - "delete": { + "/network/gateway": { + "get": { "tags": [ "network" ], - "summary": "Delete interface persistent names", - "description": "Removes interface persistent names by deleting systemd `.link` files and restoring any USB ethernet adapter config files back to `eth*` names.", + "summary": "Get default gateway", + "description": "Returns the currently configured default gateway IP address. May be empty when using DHCP.", "responses": { "200": { - "description": "Persistent names removed", + "description": "Current default gateway", "content": { "application/json": { "schema": { "type": "object" }, "example": { - "status": "OK" + "GATEWAY": "192.168.1.1" } } } @@ -4040,88 +2581,23 @@ "tags": [ "network" ], - "summary": "Set interface persistent names", - "description": "Creates interface persistent names by writing systemd `.link` files that pin each interface's name to its MAC address.", - "responses": { - "200": { - "description": "Persistent names created", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK", - "interfaceCnt": 2 - } - } - } - } - } - } - }, - "/network/wifi/scan/{interface}": { - "parameters": [ - { - "name": "interface", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "network" - ], - "summary": "Get discoverable wifi networks", - "description": "Returns information about Wi-Fi networks discoverable via the specified `{interface}`. Networks without an SSID may appear in the list.", - "responses": { - "200": { - "description": "Discoverable Wi-Fi networks", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK", - "networks": [ - { - "lastSeen": "0 ms ago", - "freq": 2437, - "signal": "-61.00 dBm", - "SSID": "Christmas" - } - ] - } + "summary": "Set default gateway", + "description": "Saves the default gateway IP address to the `gateway` configuration file.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "GATEWAY": "192.168.1.1" } } } - } - } - }, - "/network/wifi/status/{interface}": { - "parameters": [ - { - "name": "interface", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "network" - ], - "summary": "Get WiFi connection status / diagnostics", - "description": "Returns the wpa_supplicant association state for the given wireless `{interface}` plus a human-readable reason describing why it is (not) connected (wrong password, SSID not in range, waiting for DHCP, etc).", + }, "responses": { "200": { - "description": "WiFi connection status", + "description": "Default gateway saved", "content": { "application/json": { "schema": { @@ -4129,14 +2605,7 @@ }, "example": { "status": "OK", - "connected": false, - "wpa_state": "SCANNING", - "ssid": "", - "configuredSSID": "MyNet", - "ip": "", - "signal": null, - "ssidVisible": false, - "reason": "Network 'MyNet' not found in range (or it is hidden)." + "GATEWAY": "192.168.1.1" } } } @@ -4144,16 +2613,16 @@ } } }, - "/network/wifi/strength": { + "/network/interface": { "get": { "tags": [ "network" ], - "summary": "Get all wifi signal strenths", - "description": "Returns signal strength information for wireless network interfaces.", + "summary": "Get network interface details", + "description": "Returns detailed information about network interfaces, their IP addresses, and Wi-Fi signal strength.", "responses": { "200": { - "description": "Wi-Fi signal strength per interface", + "description": "Network interface details", "content": { "application/json": { "schema": { @@ -4161,10 +2630,35 @@ }, "example": [ { - "interface": "wlan0", - "link": 45, - "level": -65, - "noise": -256 + "ifindex": 2, + "ifname": "wlan0", + "flags": [ + "BROADCAST", + "MULTICAST", + "UP", + "LOWER_UP" + ], + "mtu": 1500, + "operstate": "UP", + "addr_info": [ + { + "family": "inet", + "local": "192.168.50.146", + "prefixlen": 24 + }, + { + "family": "inet6", + "local": "2001:db8::146", + "prefixlen": 64 + } + ], + "wifi": { + "interface": "wlan0", + "link": 52, + "level": -58, + "noise": -256, + "desc": "good" + } } ] } @@ -4173,10 +2667,10 @@ } } }, - "/options/{SettingName}": { + "/network/interface/add/{interface}": { "parameters": [ { - "name": "SettingName", + "name": "interface", "in": "path", "required": true, "schema": { @@ -4186,20 +2680,26 @@ ], "get": { "tags": [ - "options" + "network" + ], + "summary": "Create DHCP interface", + "description": "Creates a new blank DHCP interface configuration file for the specified network interface (e.g. `eth1`, `wlan0`).", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } ], - "summary": "Get a setting's options", - "description": "Returns the available options for the specified setting. Supports `AudioMixerDevice`, `AudioOutput`, `AudioInput`, and other platform-specific option sets.", "responses": { "200": { - "description": "Available options for the setting", + "description": "DHCP interface created", "content": { "application/json": { "schema": { "type": "object" }, "example": { - "Dummy": "0" + "status": "New Blank Interface created" } } } @@ -4207,121 +2707,10 @@ } } }, - "/overlays/effects": { - "get": { - "tags": [ - "overlays" - ], - "summary": "overlays/effects", - "description": "List the available overlay effects. Add ?full=true for full descriptions.", - "parameters": [ - { - "name": "full", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "Return full effect descriptions instead of just names." - } - ], - "responses": { - "200": { - "description": "Array of effect names (or descriptions when `full=true`)." - } - } - } - }, - "/overlays/effects/{effect}": { - "parameters": [ - { - "name": "effect", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "overlays" - ], - "summary": "overlays/effects/{effect}", - "description": "Get the description of a single overlay effect.", - "responses": { - "200": { - "description": "The effect description." - } - } - } - }, - "/overlays/fonts": { - "get": { - "tags": [ - "overlays" - ], - "summary": "overlays/fonts", - "description": "List the fonts available for overlay text effects.", - "responses": { - "200": { - "description": "Array of font names." - } - } - } - }, - "/overlays/model/{model}": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "overlays" - ], - "summary": "overlays/model/{model}", - "description": "Get a single overlay model with its current runtime state.", - "responses": { - "200": { - "description": "Model definition plus runtime state." - } - } - } - }, - "/overlays/model/{model}/clear": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "overlays" - ], - "summary": "overlays/model/{model}/clear", - "description": "Clear (blank) an overlay model's pixel buffer.", - "responses": { - "200": { - "description": "Model cleared." - } - } - } - }, - "/overlays/model/{model}/data": { + "/network/interface/{interface}": { "parameters": [ { - "name": "model", + "name": "interface", "in": "path", "required": true, "schema": { @@ -4331,117 +2720,75 @@ ], "get": { "tags": [ - "overlays" - ], - "summary": "overlays/model/{model}/data", - "description": "Get the current pixel buffer of an overlay model. Append /rle for run-length-encoded data.", - "responses": { - "200": { - "description": "Object with a `data` array (and `rle` flag)." - } - } - } - }, - "/overlays/model/{model}/fill": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "put": { - "tags": [ - "overlays" - ], - "summary": "overlays/model/{model}/fill", - "description": "Fill an overlay model with a solid color. Body: `{\"RGB\":[r,g,b]}` or `{\"Value\":v}`.", - "responses": { - "200": { - "description": "Model filled." - } - } - } - }, - "/overlays/model/{model}/mmap": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "put": { - "tags": [ - "overlays" + "network" ], - "summary": "overlays/model/{model}/mmap", - "description": "Force the overlay buffer to be memory-mapped so external programs can access it.", + "summary": "Get network interface configuration", + "description": "Retrieves the current network interface configuration.", "responses": { "200": { - "description": "Overlay buffer mmapped." - } - } - } - }, - "/overlays/model/{model}/pixel": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" + "description": "Network interface configuration", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "INTERFACE": "eth0", + "PROTO": "static", + "ADDRESS": "192.168.1.149", + "NETMASK": "255.255.255.0", + "status": "OK", + "CurrentAddress": "192.168.1.149", + "CurrentNetmask": "255.255.255.0" + } + } + } } } - ], - "put": { + }, + "post": { "tags": [ - "overlays" + "network" ], - "summary": "overlays/model/{model}/pixel", - "description": "Set a single pixel in an overlay model. Body: `{\"X\":x,\"Y\":y,\"RGB\":[r,g,b]}`.", - "responses": { - "200": { - "description": "Pixel set." - } - } - } - }, - "/overlays/model/{model}/preview": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" + "summary": "Set network interface configuration", + "description": "Updates the saved configuration for the specified `{interface}` but does not restart the network.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "INTERFACE": "eth0", + "PROTO": "static", + "ADDRESS": "192.168.1.149", + "NETMASK": "255.255.255.0", + "GATEWAY": "192.168.1.1" + } + } } - } - ], - "get": { - "tags": [ - "overlays" - ], - "summary": "overlays/model/{model}/preview", - "description": "Get the per-pixel virtual-display coordinates for a model, for a lightweight layout preview. Sourced from config/virtualdisplaymap (the xLights-exported layout) and returned on demand so the UI never has to inline this data (which can be hundreds of thousands of points per model) for every model at once.", + }, "responses": { "200": { - "description": "Object with a `pixels` array of [x, y, channel] triples." + "description": "Interface configuration saved", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } } } } }, - "/overlays/model/{model}/save": { + "/network/interface/{interface}/apply": { "parameters": [ { - "name": "model", + "name": "interface", "in": "path", "required": true, "schema": { @@ -4449,47 +2796,81 @@ } } ], - "put": { + "post": { "tags": [ - "overlays" + "network" ], - "summary": "overlays/model/{model}/save", - "description": "Save an overlay model's current buffer to an image file. Body: `{\"File\":\"name\"}`.", + "summary": "Set networking configuration", + "description": "Applies the networking settings for the specified `{interface}` at the OS level and restarts the interface.", "responses": { "200": { - "description": "Overlay saved as image." + "description": "Networking configuration applied", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "output": [] + } + } + } } } } }, - "/overlays/model/{model}/state": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" + "/network/persistentNames": { + "delete": { + "tags": [ + "network" + ], + "summary": "Delete interface persistent names", + "description": "Removes interface persistent names by deleting systemd `.link` files and restoring any USB ethernet adapter config files back to `eth*` names.", + "responses": { + "200": { + "description": "Persistent names removed", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } } } - ], - "put": { + }, + "post": { "tags": [ - "overlays" + "network" ], - "summary": "overlays/model/{model}/state", - "description": "Set an overlay model's active state. Body: `{\"State\": }`.", + "summary": "Set interface persistent names", + "description": "Creates interface persistent names by writing systemd `.link` files that pin each interface's name to its MAC address.", "responses": { "200": { - "description": "State updated." + "description": "Persistent names created", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "interfaceCnt": 2 + } + } + } } } } }, - "/overlays/model/{model}/text": { + "/network/wifi/scan/{interface}": { "parameters": [ { - "name": "model", + "name": "interface", "in": "path", "required": true, "schema": { @@ -4497,37 +2878,70 @@ } } ], - "put": { + "get": { "tags": [ - "overlays" + "network" ], - "summary": "overlays/model/{model}/text", - "description": "Render text onto an overlay model. Body includes Message, Color, Font, FontSize, Position, PixelsPerSecond, AntiAlias and optional AutoEnable.", + "summary": "Get discoverable wifi networks", + "description": "Returns information about Wi-Fi networks discoverable via the specified `{interface}`. Networks without an SSID may appear in the list.", "responses": { "200": { - "description": "Text effect started." + "description": "Discoverable Wi-Fi networks", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "networks": [ + { + "lastSeen": "0 ms ago", + "freq": 2437, + "signal": "-61.00 dBm", + "SSID": "Christmas" + } + ] + } + } + } } } } }, - "/overlays/models": { + "/network/wifi/strength": { "get": { "tags": [ - "overlays" + "network" ], - "summary": "overlays/models", - "description": "List all overlay models with their current runtime state (active state, running effect, dimensions).", + "summary": "Get all wifi signal strenths", + "description": "Returns signal strength information for wireless network interfaces.", "responses": { "200": { - "description": "Array of models with runtime state." + "description": "Wi-Fi signal strength per interface", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "interface": "wlan0", + "link": 45, + "level": -65, + "noise": -256 + } + ] + } + } } } } }, - "/overlays/range/{ranges}": { + "/options/{SettingName}": { "parameters": [ { - "name": "ranges", + "name": "SettingName", "in": "path", "required": true, "schema": { @@ -4535,43 +2949,25 @@ } } ], - "put": { - "tags": [ - "overlays" - ], - "summary": "overlays/range/{ranges}", - "description": "Set, update, or delete active overlay channel ranges. Body: `{\"Value\":v}`, `{\"delete\":true}`, or `{\"deleteAll\":true}`.", - "responses": { - "200": { - "description": "Ranges updated." - } - } - } - }, - "/overlays/running": { - "get": { - "tags": [ - "overlays" - ], - "summary": "overlays/running", - "description": "List the overlay effects that are currently running.", - "responses": { - "200": { - "description": "Active overlay effects." - } - } - } - }, - "/overlays/settings": { "get": { "tags": [ - "overlays" + "options" ], - "summary": "overlays/settings", - "description": "Get the pixel-overlay manager settings.", + "summary": "Get a setting's options", + "description": "Returns the available options for the specified setting. Supports `AudioMixerDevice`, `AudioOutput`, `AudioInput`, and other platform-specific option sets.", "responses": { "200": { - "description": "Object with the `autoCreate` flag." + "description": "Available options for the setting", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Dummy": "0" + } + } + } } } } @@ -5470,48 +3866,6 @@ } } }, - "/player": { - "get": { - "tags": [ - "player" - ], - "summary": "player", - "description": "Get the player status. Equivalent to /api/player/status.", - "responses": { - "200": { - "description": "Player status JSON." - } - } - } - }, - "/player/current": { - "get": { - "tags": [ - "player" - ], - "summary": "player/current", - "description": "Get information about the currently playing playlist.", - "responses": { - "200": { - "description": "Object with a `playlist` member describing the current playlist." - } - } - } - }, - "/player/status": { - "get": { - "tags": [ - "player" - ], - "summary": "player/status", - "description": "Get the player status (playlist, sequence, timing, mode, etc.).", - "responses": { - "200": { - "description": "Player status JSON." - } - } - } - }, "/playlist/{PlaylistName}": { "parameters": [ { @@ -5674,6 +4028,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -5723,6 +4081,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "parameters": [ @@ -5791,6 +4153,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -5966,6 +4332,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -6023,6 +4393,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -6054,6 +4428,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -6085,6 +4463,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -6116,6 +4498,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -6237,23 +4623,6 @@ } } }, - "/plugin/fetchImage?url=...": { - "get": { - "tags": [ - "plugin" - ], - "summary": "Proxy-fetch a plugin icon image", - "description": "Fetches an image from an external URL and serves it with the correct content-type. Used to bypass CSP restrictions that block loading images from external hosts (e.g. raw.githubusercontent.com) directly in `` tags.", - "responses": { - "200": { - "description": "Image data" - }, - "400": { - "description": "Missing or invalid URL" - } - } - } - }, "/plugin/fetchInfo": { "post": { "tags": [ @@ -6289,47 +4658,6 @@ } } }, - "/plugin/githubStats": { - "get": { - "tags": [ - "plugin" - ], - "summary": "Get open issue / open PR counts for a list of GitHub repos.", - "description": "Developer-UI helper for the plugin cards. Accepts a comma-separated list of `owner/name` repos and returns per-repo `{ openIssues, openPRs }`. Counts are proxied from GitHub's issue-search API (one aggregate query per group of repos, cached per box), never one request per plugin -- which is what floods the device with 404s when there is no network or a repo is gone. Fail-soft: on any upstream problem it serves a stale cache if present, else an empty map (`source: unavailable`); the UI then hides the corner counts.", - "parameters": [ - { - "name": "repos", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Comma-separated `owner/name` GitHub repos." - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "repos": { - "FalconChristmas/fpp-brightness": { - "openIssues": 7, - "openPRs": 1 - } - }, - "source": "live|cache|partial|unavailable" - } - } - } - } - } - } - }, "/plugin/headerIndicators": { "get": { "tags": [ @@ -6348,38 +4676,10 @@ "example": [ { "pluginName": "fpp-matrixtools", - "label": "1", - "color": "red" - } - ] - } - } - } - } - } - }, - "/plugin/popularity": { - "get": { - "tags": [ - "plugin" - ], - "summary": "plugin/popularity", - "description": "Get plugin install-popularity counts (repoName -> install count, last 365 days), proxied + cached from the community stats feed.", - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "period": "last365Days", - "counts": { - "remote-falcon": 1680 - }, - "source": "live|cache|stale|unavailable" - } + "label": "1", + "color": "red" + } + ] } } } @@ -6455,69 +4755,6 @@ } } }, - "/plugin/{RepoName}/icon": { - "parameters": [ - { - "name": "RepoName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "plugin" - ], - "summary": "Serve plugin icon", - "description": "Serves the plugin icon. First checks for a local icon.png in the plugin directory. If not found, checks the plugin's pluginInfo.json for an iconURL field and proxies it (same-origin, avoids CSP restrictions on external image hosts).", - "responses": { - "200": { - "description": "PNG image data" - }, - "404": { - "description": "No icon available" - } - } - } - }, - "/plugin/{RepoName}/page": { - "parameters": [ - { - "name": "RepoName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "plugin" - ], - "summary": "Get plugin page URL", - "description": "Scans the plugin's menu files (menu.inc, status_menu.inc, etc.) and returns the best page URL for the plugin. Prefers the Status/Control page; falls back to the Content Setup (config) page if no status page is found.", - "responses": { - "200": { - "description": "Plugin page info", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "url": "plugin.php?plugin=fpp-matrixtools&page=status.php", - "page": "status.php", - "found": true - } - } - } - } - } - } - }, "/plugin/{RepoName}/settings/{SettingName}": { "parameters": [ { @@ -6647,6 +4884,12 @@ ], "summary": "Update plugin", "description": "Pull in git updates for plugin `{RepoName}`. Supports an optional `?stream=true` query parameter for streaming output.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "parameters": [ { "name": "stream", @@ -6904,25 +5147,13 @@ } } }, - "/recurringtasks": { - "get": { - "tags": [ - "recurringtasks" - ], - "summary": "recurringtasks", - "description": "Report the configured Recurring Tasks merged with last-run status, for the Recurring Tasks admin page (www/recurringtasks.php).", - "responses": { - "200": { - "description": "{\"tasks\": [...]}" - } - } - }, + "/remoteAction": { "post": { "tags": [ - "recurringtasks" + "remoteAction" ], - "summary": "recurringtasks", - "description": "Re-read config/recurringtasks.json and re-schedule all Recurring Task timers to match, so a save on www/recurringtasks.php (which writes that file via the generic api/configfile endpoint) takes effect without an fppd restart. Also handles an immediate \"Test Run\" of one task, run synchronously against the posted task definition (not a saved name), so the admin page can preview unsaved edits.", + "summary": "Proxy a command to remote FPP (v1 \u2014 query parameters)", + "description": "Proxies a named action to a remote FPP instance by IP address. Supported actions: `listUpgrades`, `reboot`, `restartFppd`, `upgradeOS`.", "requestBody": { "content": { "application/json": { @@ -6930,28 +5161,12 @@ "type": "object" }, "example": { - "command": "reload" + "ip": "192.168.1.100", + "action": "reboot" } } } }, - "responses": { - "200": { - "description": "Recurring tasks reloaded, or {\"ok\":bool,\"raw\":string,\"filtered\":string,\"error\":string} for \"test\"." - }, - "400": { - "description": "'command' field not specified." - } - } - } - }, - "/remoteAction": { - "get": { - "tags": [ - "remoteAction" - ], - "summary": "Proxy a command to remote FPP (v1 \u2014 query parameters)", - "description": "Proxies a named action to a remote FPP instance by IP address. Supported actions: `listUpgrades`, `reboot`, `restartFppd`, `upgradeOS`.", "responses": { "400": { "description": "Invalid action", @@ -7174,6 +5389,12 @@ ], "summary": "Install remote script", "description": "Installs a remote script from the script repository.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { "description": "Remote script installed", @@ -7304,6 +5525,12 @@ ], "summary": "Run script", "description": "Runs a locally installed script.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { "description": "Script output", @@ -7356,6 +5583,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -7390,6 +5621,10 @@ { "name": "DEVELOPER ONLY", "color": "#546e7a" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -7424,6 +5659,10 @@ { "name": "DEVELOPER ONLY", "color": "#546e7a" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -7626,6 +5865,10 @@ { "name": "DEVELOPER ONLY", "color": "#546e7a" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -7688,30 +5931,6 @@ } } }, - "/settings/fanThermal/reset": { - "post": { - "tags": [ - "settings" - ], - "summary": "Reset fan thermal trip settings", - "description": "Removes all FanTrip_* settings and restores the hardware (device tree) default trip temperatures that fppinit captured at boot.", - "responses": { - "200": { - "description": "Settings reset", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK" - } - } - } - } - } - } - }, "/settings/{SettingName}": { "parameters": [ { @@ -7953,6 +6172,12 @@ ], "summary": "Restart fppd process", "description": "Restarts the `fppd` process. Pass `?quick=1` to reload some configuration without a full restart.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "parameters": [ { "name": "quick", @@ -8013,6 +6238,12 @@ ], "summary": "Start fppd", "description": "Starts the `fppd` process idempotently (if it isn't already running).", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { "description": "fppd started", @@ -8037,6 +6268,12 @@ ], "summary": "Stop fppd", "description": "Stops the `fppd` process if it is running.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { "description": "fppd stopped", @@ -8172,19 +6409,15 @@ ], "summary": "Reboot the operating system", "description": "Reboots the operating system.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { - "description": "Reboot initiated", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK" - } - } - } + "description": "Reboot initiated" } } } @@ -8234,19 +6467,15 @@ ], "summary": "Shutdown the operating system", "description": "Executes a clean shutdown of the operating system.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { - "description": "Shutdown initiated", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK" - } - } - } + "description": "Shutdown initiated" } } } @@ -8488,133 +6717,6 @@ } } } - }, - "/variables": { - "get": { - "tags": [ - "variables" - ], - "summary": "variables", - "description": "List all User Variables and their current values. Pass ?validateExpression instead to syntax-check an expression (for the Set Variable \"Expression\" field) against currently-known variables, without setting anything. Pass ?fpp=true instead to list the read-only \"fpp_\" status variables (current playlist/sequence, play state, volume, etc.) instead of User Variables.", - "parameters": [ - { - "name": "validateExpression", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "If set, validate this expression instead of listing variables." - }, - { - "name": "conditionExpr", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "If \"true\" alongside validateExpression, classify it the way the If" - }, - { - "name": "fpp", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "If \"true\", list the read-only fpp- status variables instead of User Variables." - }, - { - "name": "mqtt", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "If \"true\", list the read-only mqtt- variables (MQTT's own last-message-" - } - ], - "responses": { - "200": { - "description": "Object keyed by variable name, each with `value`, `truncated`," - } - } - } - }, - "/variables/{name}": { - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "delete": { - "tags": [ - "variables" - ], - "summary": "variables/{name}", - "description": "Delete a User Variable entirely - unlike POSTing an empty body (the UI's \"Clear\"), which only resets its value/persist flag and leaves the row behind, this removes the name from the list altogether.", - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Missing variable name in the path, or name is a read-only" - } - } - }, - "get": { - "tags": [ - "variables" - ], - "summary": "variables/{name}", - "description": "Read a single User Variable's current value.", - "responses": { - "200": { - "description": "The variable's value as plain text (empty string if unset)." - } - } - }, - "post": { - "tags": [ - "variables" - ], - "summary": "variables/{name}", - "description": "Set a User Variable's value. Body is the raw new value (plain text, not JSON). Add ?persist=true to save it to config/variables.json so it survives an fppd restart; omitted or any other value means the variable is in-memory only.", - "parameters": [ - { - "name": "persist", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - }, - "description": "Persist the value to disk so it survives a restart." - } - ], - "requestBody": { - "content": { - "text/plain": { - "schema": { - "type": "string" - }, - "example": "new-value-goes-here" - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Missing variable name in the path." - } - } - } } } } \ No newline at end of file diff --git a/www/api/v2/openapi.json b/www/api/v2/openapi.json index 9f98b1be1..52b8b44d3 100644 --- a/www/api/v2/openapi.json +++ b/www/api/v2/openapi.json @@ -42,9 +42,6 @@ { "name": "files" }, - { - "name": "geoip" - }, { "name": "git" }, @@ -1985,13 +1982,13 @@ } }, "403": { - "description": "Forbidden directory", + "description": "Forbidden filename", "content": { "text/plain": { "schema": { "type": "string" }, - "example": "Tail follow is only allowed for log files." + "example": "Invalid file path." } } }, @@ -2073,31 +2070,6 @@ } } }, - "/files/Sequences/fps": { - "get": { - "tags": [ - "files" - ], - "summary": "Get sequence frame rates (fps)", - "description": "Returns a map of sequence filename => fps for every `.fseq` file in the sequence directory. This is intentionally split out from the main file listing so the file manager can render the sequence list immediately and lazily fill in the FPS column via this endpoint. The fps is derived from the fseq header StepTime (fps = round(1000 / StepTime)) and cached per file (keyed on name + size) so fsequtils is only run on cache misses.", - "responses": { - "200": { - "description": "Map of sequence filename to fps", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "GreatestShow.fseq": 40, - "subdir/Intro.fseq": 20 - } - } - } - } - } - } - }, "/files/zip/{DirNames}": { "parameters": [ { @@ -2184,48 +2156,6 @@ } } }, - "/geoip": { - "get": { - "tags": [ - "geoip" - ], - "summary": "GeoIP lookup", - "description": "Server-side proxy for ipapi.co's IP geolocation lookup, used by the Timezone/GeoLocation \"Lookup\"/\"Detect\" buttons on settings.php. ipapi.co does not send Access-Control-Allow-Origin, so the browser can't call it directly from FPP's UI (blocked by the Same Origin Policy) - PHP isn't subject to that, so we fetch it here and hand back the same JSON.", - "responses": { - "200": { - "description": "ipapi.co's JSON response, passed through unmodified", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "ip": "1.2.3.4", - "city": "Adelaide", - "region": "South Australia", - "timezone": "Australia/Adelaide", - "latitude": -34.9, - "longitude": 138.6 - } - } - } - }, - "502": { - "description": "Lookup failed", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "error": "GeoIP lookup failed" - } - } - } - } - } - } - }, "/git/branches": { "get": { "tags": [ @@ -2290,36 +2220,6 @@ } } }, - "/git/releases/notes/{tag}": { - "parameters": [ - { - "name": "tag", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "git" - ], - "summary": "Get OS release notes for a tag", - "description": "Proxies the GitHub `FalconChristmas/fpp` release-by-tag API so the browser does not call api.github.com directly (same pattern/UA/timeout as GitOSReleases). Returns the raw GitHub release object on success; a non-200 status otherwise so the caller's error handler fires.", - "responses": { - "200": { - "description": "GitHub release object for the tag" - }, - "400": { - "description": "Invalid tag" - }, - "404": { - "description": "Release not found for the tag" - } - } - } - }, "/git/releases/os/{All}": { "parameters": [ { @@ -2979,48 +2879,6 @@ } } }, - "/network/wifi/status/{interface}": { - "parameters": [ - { - "name": "interface", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "network" - ], - "summary": "Get WiFi connection status / diagnostics", - "description": "Returns the wpa_supplicant association state for the given wireless `{interface}` plus a human-readable reason describing why it is (not) connected (wrong password, SSID not in range, waiting for DHCP, etc).", - "responses": { - "200": { - "description": "WiFi connection status", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK", - "connected": false, - "wpa_state": "SCANNING", - "ssid": "", - "configuredSSID": "MyNet", - "ip": "", - "signal": null, - "ssidVisible": false, - "reason": "Network 'MyNet' not found in range (or it is hidden)." - } - } - } - } - } - } - }, "/network/wifi/strength": { "get": { "tags": [ @@ -4703,23 +4561,6 @@ } } }, - "/plugin/fetchImage?url=...": { - "get": { - "tags": [ - "plugin" - ], - "summary": "Proxy-fetch a plugin icon image", - "description": "Fetches an image from an external URL and serves it with the correct content-type. Used to bypass CSP restrictions that block loading images from external hosts (e.g. raw.githubusercontent.com) directly in `` tags.", - "responses": { - "200": { - "description": "Image data" - }, - "400": { - "description": "Missing or invalid URL" - } - } - } - }, "/plugin/fetchInfo": { "post": { "tags": [ @@ -4755,47 +4596,6 @@ } } }, - "/plugin/githubStats": { - "get": { - "tags": [ - "plugin" - ], - "summary": "Get open issue / open PR counts for a list of GitHub repos.", - "description": "Developer-UI helper for the plugin cards. Accepts a comma-separated list of `owner/name` repos and returns per-repo `{ openIssues, openPRs }`. Counts are proxied from GitHub's issue-search API (one aggregate query per group of repos, cached per box), never one request per plugin -- which is what floods the device with 404s when there is no network or a repo is gone. Fail-soft: on any upstream problem it serves a stale cache if present, else an empty map (`source: unavailable`); the UI then hides the corner counts.", - "parameters": [ - { - "name": "repos", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Comma-separated `owner/name` GitHub repos." - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "repos": { - "FalconChristmas/fpp-brightness": { - "openIssues": 7, - "openPRs": 1 - } - }, - "source": "live|cache|partial|unavailable" - } - } - } - } - } - } - }, "/plugin/headerIndicators": { "get": { "tags": [ @@ -4824,34 +4624,6 @@ } } }, - "/plugin/popularity": { - "get": { - "tags": [ - "plugin" - ], - "summary": "plugin/popularity", - "description": "Get plugin install-popularity counts (repoName -> install count, last 365 days), proxied + cached from the community stats feed.", - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "period": "last365Days", - "counts": { - "remote-falcon": 1680 - }, - "source": "live|cache|stale|unavailable" - } - } - } - } - } - } - }, "/plugin/{RepoName}": { "parameters": [ { @@ -4921,69 +4693,6 @@ } } }, - "/plugin/{RepoName}/icon": { - "parameters": [ - { - "name": "RepoName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "plugin" - ], - "summary": "Serve plugin icon", - "description": "Serves the plugin icon. First checks for a local icon.png in the plugin directory. If not found, checks the plugin's pluginInfo.json for an iconURL field and proxies it (same-origin, avoids CSP restrictions on external image hosts).", - "responses": { - "200": { - "description": "PNG image data" - }, - "404": { - "description": "No icon available" - } - } - } - }, - "/plugin/{RepoName}/page": { - "parameters": [ - { - "name": "RepoName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { - "tags": [ - "plugin" - ], - "summary": "Get plugin page URL", - "description": "Scans the plugin's menu files (menu.inc, status_menu.inc, etc.) and returns the best page URL for the plugin. Prefers the Status/Control page; falls back to the Content Setup (config) page if no status page is found.", - "responses": { - "200": { - "description": "Plugin page info", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "url": "plugin.php?plugin=fpp-matrixtools&page=status.php", - "page": "status.php", - "found": true - } - } - } - } - } - } - }, "/plugin/{RepoName}/settings/{SettingName}": { "parameters": [ { @@ -6126,30 +5835,6 @@ } } }, - "/settings/fanThermal/reset": { - "post": { - "tags": [ - "settings" - ], - "summary": "Reset fan thermal trip settings", - "description": "Removes all FanTrip_* settings and restores the hardware (device tree) default trip temperatures that fppinit captured at boot.", - "responses": { - "200": { - "description": "Settings reset", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK" - } - } - } - } - } - } - }, "/settings/{SettingName}": { "parameters": [ { @@ -6612,17 +6297,7 @@ "description": "Reboots the operating system.", "responses": { "200": { - "description": "Reboot initiated", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK" - } - } - } + "description": "Reboot initiated" } } } @@ -6674,17 +6349,7 @@ "description": "Executes a clean shutdown of the operating system.", "responses": { "200": { - "description": "Shutdown initiated", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "status": "OK" - } - } - } + "description": "Shutdown initiated" } } } From dd58b5b1712477fd7b6ddb55297821093c49b2da Mon Sep 17 00:00:00 2001 From: "Justin J. Novack" Date: Sat, 1 Aug 2026 11:56:03 -0400 Subject: [PATCH 11/11] fix(api): rename effects/stats/testmode handlers to match dispatch table The api-v2 rewrite renamed every route's dispatch-table target in index.php to PascalCase, but never touched the function definitions in these three controllers, which still used their old names: - effects.php: effects_list() -> EffectsList() effects_list_ALL() -> EffectsListAll() - stats.php: stats_get_last_file() -> StatsGetLastFile() stats_publish_stats_file() -> StatsPublishStatsFile() stats_delete_last_file() -> StatsDeleteLastFile() - testmode.php: testMode_Get() -> TestModeGet() testMode_Set() -> TestModeSet() Each affected route (GET/ALL /effects, GET/POST/DELETE /statistics/usage, GET/POST /testmode) called a function that didn't exist, so hitting any of them threw a fatal "call to undefined function" error. Also updated the one internal caller (StatsPublishStatsFile -> StatsGetLastFile) affected by the stats.php rename. Verified by cross-checking every dispatch_* target in index.php against the functions actually defined in controllers/*.php -- no dangling routes remain. --- www/api/controllers/effects.php | 4 ++-- www/api/controllers/stats.php | 8 ++++---- www/api/controllers/testmode.php | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/www/api/controllers/effects.php b/www/api/controllers/effects.php index 939b18e41..5f803aade 100644 --- a/www/api/controllers/effects.php +++ b/www/api/controllers/effects.php @@ -12,7 +12,7 @@ * ["rainbow", "twinkle"] * ``` */ -function effects_list() +function EffectsList() { global $effectDirectory; @@ -43,7 +43,7 @@ function effects_list() * ["rainbow", "twinkle", "MySequence"] * ``` */ -function effects_list_ALL() +function EffectsListAll() { global $effectDirectory; global $sequenceDirectory; diff --git a/www/api/controllers/stats.php b/www/api/controllers/stats.php index 79211ee22..17929dde4 100644 --- a/www/api/controllers/stats.php +++ b/www/api/controllers/stats.php @@ -81,7 +81,7 @@ function stats_generate($statsFile) * } * ``` */ -function stats_get_last_file() +function StatsGetLastFile() { global $_GET; $statsFile = stats_get_filename(); @@ -226,10 +226,10 @@ function stats_memory() * {"status": "OK", "uuid": "M2-xxxxxxxx-f67f-930d-56ee-7xxxxxxxxxx"} * ``` */ -function stats_publish_stats_file() +function StatsPublishStatsFile() { global $settings; - $jsonString = stats_get_last_file(); + $jsonString = StatsGetLastFile(); $ch = curl_init($settings['statsPublishUrl']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); @@ -257,7 +257,7 @@ function stats_publish_stats_file() * {"status": "OK"} * ``` */ -function stats_delete_last_file() +function StatsDeleteLastFile() { $statsFile = stats_get_filename(); if (file_exists($statsFile)) { diff --git a/www/api/controllers/testmode.php b/www/api/controllers/testmode.php index 203c8ae07..477bdaa01 100644 --- a/www/api/controllers/testmode.php +++ b/www/api/controllers/testmode.php @@ -23,7 +23,7 @@ * } * ``` */ -function testMode_Get() +function TestModeGet() { return json(json_decode(SendCommand("GetTestMode"))); } @@ -42,7 +42,7 @@ function testMode_Get() * { "status": "OK" } * ``` */ -function testMode_Set() +function TestModeSet() { $json = strval(file_get_contents('php://input'));
EndpointDescriptionInput JSONOutput JSON
%s%s%s%s