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/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..a825ead61 --- /dev/null +++ b/tests/playwright/tests/api-v2/audio.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('audio', () => { + + test('GET /audio/cardaliases', { tag: ['@hardware:audio', '@schema'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + 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', '@schema'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + 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..89070ae7d --- /dev/null +++ b/tests/playwright/tests/api-v2/backups.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('backups', () => { + + test('End-to-End Backup and Restore', { tag: ['@e2e'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'END2END' }); + const backupList = await request.get(`${V2}/backups/list`); // GET /backups/list + expect(backupList.status()).toBe(200); + const backupListBody = await backupList.json(); + expect(Array.isArray(backupListBody)).toBe(true); + + const create = await request.post(`${V2}/backups/configuration`); // POST /backups/configuration + expect(create.status()).toBe(200); + const body = await create.json(); + expect(body).toHaveProperty('success', true); + expect(body).toHaveProperty('backup_file_path'); + expect(typeof body.backup_file_path).toBe('string'); + + const createdBackupPath: string = body.backup_file_path; + const file = createdBackupPath.substring(createdBackupPath.lastIndexOf('/') + 1); + + const list = await request.get(`${V2}/backups/configuration/list`); // GET /backups/configuration/list + expect(list.status()).toBe(200); + const backups = await list.json(); + expect(Array.isArray(backups)).toBe(true); + expect(backups.length).toBeGreaterThan(0); + expect(backups[0]).toHaveProperty('backup_filedirectory'); + expect(backups[0]).toHaveProperty('backup_filename'); + + const listedBackupPath = `${String(backups[0].backup_filedirectory).replace(/\/$/, '')}/${backups[0].backup_filename}`; + expect(listedBackupPath).toBe(createdBackupPath); + + const directory = backups[0].backup_alternative_location ? 'JsonBackupsAlternate' : 'JsonBackups'; + + const download = await request.get(`${V2}/backups/configuration/${directory}/${file}`); // GET /backups/configuration/:Directory/:BackupFilename + expect(download.status()).toBe(200); + expect(download.headers()['content-type']).toContain('application/json'); + const downloadedBackup = JSON.parse(await download.text()); + expect(downloadedBackup).toBeTruthy(); + expect(typeof downloadedBackup).toBe('object'); + + const restore = await request.post(`${V2}/backups/configuration/restore/${directory}/${file}`, { // POST /backups/configuration/restore/:Directory/:BackupFilename + headers: { 'Content-Type': 'text/plain' }, + data: 'all', + }); + expect(restore.status()).toBe(200); + const restoreBody = await restore.json(); + expect(restoreBody.Success).toBeTruthy(); + + const del = await request.delete(`${V2}/backups/configuration/JsonBackups/${file}`); // DELETE /backups/configuration/:Directory/:BackupFilename + expect(del.status()).toBe(200); + }); + + test('GET /backups/list', { tag: ['@schema'] }, async () => { + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + // Covered inline by the End to End Backup and Restore STATE test above + test.skip(true, 'Covered inline by End to End Backup and Restore test'); + }); + + test('GET /backups/list/:DeviceName', { tag: ['@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // Requires: external device mounted with a known DeviceName + test.skip(true, 'No test written. Requires mounted external backup device'); + }); + + test('GET /backups/devices', { tag: ['@schema'] }, 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('GET /backups/configuration/list/:DeviceName', { tag: ['@hardware:external'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + // Requires: external device mounted at a known DeviceName + test.skip(true, 'Requires mounted external backup device'); + }); + + test('POST /backups/devices/mount/:DeviceName/:MountLocation', { tag: ['@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // Requires: a known removable device path on the system + test.skip(true, 'No test written. Requires a physically attached backup device'); + }); + + test('POST /backups/devices/unmount/:DeviceName/:MountLocation', { tag: ['@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // Requires: a mounted device that can be safely unmounted + test.skip(true, 'No test written. Requires a mounted backup device'); + }); + + test('POST /backups/configuration', { tag: ['@covered'] }, async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End to End Backup and Restore test'); + }); + + test('GET /backups/configuration/list', { tag: ['@covered'] }, async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End to End Backup and Restore test'); + }); + + test('POST /backups/configuration/restore/:Directory/:BackupFilename', { tag: ['@covered'] }, async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End to End Backup and Restore test'); + }); + + test('GET /backups/configuration/:Directory/:BackupFilename', { tag: ['@covered'] }, async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End to End Backup and Restore test'); + }); + + test('DELETE /backups/configuration/:Directory/:BackupFilename', { tag: ['@covered'] }, async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End to End Backup and Restore 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..9effe6305 --- /dev/null +++ b/tests/playwright/tests/api-v2/cape.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('cape', () => { + + test('GET /cape', { tag: ['@hardware:cape', '@schema'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + test.info().annotations.push({ type: 'tier', description: 'SCHEMA' }); + const res = await request.get(`${V2}/cape`); + expect(res.status()).toBe(404); + const body = await res.json(); + expect(body).not.toBeNull(); + expect(typeof body).toBe('object'); + }); + + test('POST /cape/eeprom/voucher', { tag: ['@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // 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/sign/:key/:order', { tag: ['@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // 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: ['@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // 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: ['@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // Requires: valid key and order values from a real cape provisioning workflow + test.skip(true, 'No test written. Requires valid cape signing key and order'); + }); + + test('POST /cape/eeprom/signingData', { tag: ['@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // Requires: valid key and order values from a real cape provisioning workflow + test.skip(true, 'No test written. Requires valid cape signing key and order'); + }); + + test('GET /cape/options', { tag: ['@schema'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'HARDWARE' }); + 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(body[0]).toBe("--None--"); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /cape/strings', { tag: ['@schema'] }, 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', { tag: ['@schema'] }, 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: ['@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // Requires: cape with string outputs configured; key must be a valid string config key + test.skip(true, 'No test written. Requires physical cape with string outputs'); + }); + + test('GET /cape/panel/:key', { tag: ['@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // Requires: cape with panel outputs configured; key must be a valid panel config key + test.skip(true, 'No test written. 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..744593eb5 --- /dev/null +++ b/tests/playwright/tests/api-v2/configfile.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('configfile', () => { + const traversalReadPath = '../fpp-info.json'; + const traversalWritePath = '../evil.txt'; + + test('End-to-End /configfile Test', { tag: ['@e2e'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'END2END' }); + const testPath = 'ci/e2e-configfile.txt'; + const content = '# CI test config file\n'; + + const listRes = await request.get(`${V2}/configfile`); // GET /configfile + expect(listRes.status()).toBe(200); + const listBody = await listRes.json(); + expect(listBody).toHaveProperty('Path', ''); + expect(Array.isArray(listBody.ConfigFiles)).toBe(true); + + const uploadRes = await request.post(`${V2}/configfile/${testPath}`, { // POST /configfile/** + headers: { 'Content-Type': 'text/plain' }, + data: content, + }); + expect(uploadRes.status()).toBe(200); + expect(await uploadRes.json()).toEqual({ Status: 'OK', Message: '' }); + + const downloadRes = await request.get(`${V2}/configfile/${testPath}`); // GET /configfile/** + expect(downloadRes.status()).toBe(200); + expect(await downloadRes.text()).toBe(content); + + const delRes = await request.delete(`${V2}/configfile/${testPath}`); // DELETE /configfile/** + expect(delRes.status()).toBe(200); + expect(await delRes.json()).toEqual({ Status: 'OK', Message: '' }); + }); + + test('GET /configfile', { tag: ['@covered'] }, async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End-to-End /configfile test'); + }); + + test('POST /configfile/**', { tag: ['@covered'] }, async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End-to-End /configfile test'); + }); + + test('GET /configfile/**', { tag: ['@covered'] }, async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End-to-End /configfile test'); + }); + + test('DELETE /configfile/**', { tag: ['@covered'] }, async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End-to-End /configfile test'); + }); + + test('GET /configfile/** blocks ../fpp-info.json traversal', { tag: ['@full', '@security'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SECURITY' }); + const res = await request.get(`${V2}/configfile/${traversalReadPath}`); + expect(res.status()).toBe(404); + }); + + test('POST /configfile/** blocks ../evil.txt traversal and follow-up read', { tag: ['@full', '@security'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SECURITY' }); + const postRes = await request.post(`${V2}/configfile/${traversalWritePath}`, { + headers: { 'Content-Type': 'text/plain' }, + data: 'hello world', + }); + expect(postRes.status()).toBe(404); + + const getRes = await request.get(`${V2}/configfile/${traversalWritePath}`); + expect(getRes.status()).toBe(404); + }); + + test('DELETE /configfile/** blocks ../fpp-info.json traversal', { tag: ['@full', '@security'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'SECURITY' }); + const res = await request.delete(`${V2}/configfile/${traversalReadPath}`); + expect(res.status()).toBe(404); + }); + +}); 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..f2b3af8f7 --- /dev/null +++ b/tests/playwright/tests/api-v2/dir.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('dir', () => { + + test('End-to-End /dir Test', { tag: ['@e2e'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'END2END' }); + const dirName = 'sequences'; + const subdir = 'ci-test-dir'; + + // Delete if exists + const cleanupRes = await request.delete(`${V2}/dir/${dirName}/${subdir}`); // DELETE /dir/{DirName}/{SubDir} + expect(cleanupRes.status()).toBe(200); + + // Create + const createRes = await request.post(`${V2}/dir/${dirName}/${subdir}`); // POST /dir/{DirName}/{SubDir} + expect(createRes.status()).toBe(200); + expect(await createRes.json()).toEqual({ + status: 'OK', + subdir, + dir: dirName, + }); + + // Attempt to create again, get failure + const createAgainRes = await request.post(`${V2}/dir/${dirName}/${subdir}`); + expect(createAgainRes.status()).toBe(200); + expect(await createAgainRes.json()).toEqual({ + status: 'Subdirectory already exists', + subdir, + dir: dirName, + }); + + // Delete + const delRes = await request.delete(`${V2}/dir/${dirName}/${subdir}`); + expect(delRes.status()).toBe(200); + expect(await delRes.json()).toEqual({ + status: 'OK', + subdir, + dir: dirName, + }); + }); + + test('POST /dir/**', async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End-to-End /dir test'); + }); + + test('DELETE /dir/**', async () => { + test.info().annotations.push({ type: 'tier', description: 'COVERED' }); + test.skip(true, 'Covered inline by End-to-End /dir test'); + }); + +}); 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..9add3bf38 --- /dev/null +++ b/tests/playwright/tests/api-v2/email.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test'; + +const V2 = '/api/v2'; + +test.describe('email', () => { + + test('POST /email/configure', { tag: ['@email', '@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'EMAIL' }); + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // Requires: valid SMTP config values; this will mutate the email configuration on the device + test.skip(true, 'No test written. Requires valid SMTP credentials and mail server config'); + }); + + test('POST /email/test', { tag: ['@email', '@notest'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'EMAIL' }); + test.info().annotations.push({ type: 'tier', description: 'NOTEST' }); + // This sends an actual email to the configured recipient; only run with a real mail config + test.skip(true, 'No test written. 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..77b865dab --- /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', { tag: ['@schema'] }, 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..6d480dd3f --- /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', { tag: ['@full'] }, async ({ request }) => { + test.info().annotations.push({ type: 'tier', description: 'FULL' }); + 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..652b70921 --- /dev/null +++ b/www/api/MIGRATION.md @@ -0,0 +1,104 @@ +# 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. + +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 + +**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..713a528ba 100644 --- a/www/api/README.md +++ b/www/api/README.md @@ -1,36 +1,62 @@ # 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_v1.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-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 -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,21 +66,63 @@ 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`. --- ## 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 @@ -69,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} */ ``` @@ -83,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} */ ``` @@ -93,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": "..."} @@ -110,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 @@ -128,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 @@ -202,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 @@ -215,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/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; -} - -?> diff --git a/www/api/controllers/helpers.php b/www/api/controllers/helpers.php new file mode 100644 index 000000000..aa32c33ec --- /dev/null +++ b/www/api/controllers/helpers.php @@ -0,0 +1,41 @@ + 'error', 'message' => 'Request body is required']); + exit; + } + return null; + } + + $data = json_decode($raw, true); + + if ($data === null && json_last_error() !== JSON_ERROR_NONE) { + if ($required) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Invalid JSON: ' . json_last_error_msg()]); + exit; + } + return null; + } + + return $data; +} 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 831c04127..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 * [ @@ -33,7 +34,7 @@ * ] * ``` */ -function network_list_interfaces() +function NetworkListInterfaces() { return json(network_list_interfaces_obj()); } @@ -43,7 +44,8 @@ function network_list_interfaces() * * 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 * [ @@ -56,7 +58,7 @@ function network_list_interfaces() * ] * ``` */ -function network_wifi_strength() +function NetworkWiFiStrength() { return json(network_wifi_strength_obj()); } @@ -67,7 +69,8 @@ function network_wifi_strength() * 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 * { @@ -98,7 +101,7 @@ function network_wifi_strength() * Each BSS begins a new entry; only entries that yielded at least an SSID or * BSSID are kept so a leading/empty block is never returned. */ -function network_parse_iw_scan($output) +function NetworkParseIwScan($output) { $networks = array(); $current = array(); @@ -153,7 +156,7 @@ function network_parse_iw_scan($output) * shape as network_parse_iw_scan(). Used as a fallback for out-of-tree * drivers whose nl80211 scan support is missing or broken. */ -function network_parse_iwlist_scan($output) +function NetworkParseIwlistScan($output) { $networks = array(); $current = array(); @@ -207,7 +210,7 @@ function network_parse_iwlist_scan($output) * * Returns array("mode" => "AP"|"station"|"idle", "clientCount" => int, "ssid" => string) */ -function network_interface_in_use($interface) +function NetworkInterfaceInUse($interface) { $iface = escapeshellarg($interface); @@ -256,14 +259,14 @@ function network_interface_in_use($interface) return array("mode" => "idle", "clientCount" => 0, "ssid" => ""); } -function network_wifi_scan() +function NetworkWiFiScan() { $networks = array(); $current = array(); $interface = params('interface'); # Validate interface. -- Important because of SUDO - $interfaces = json_decode(network_list_interfaces(), true); + $interfaces = json_decode(NetworkListInterfaces(), true); $found = false; foreach ($interfaces as $row) { @@ -278,7 +281,7 @@ function network_wifi_scan() exec("sudo /sbin/ip link set $interface up", $output); - $inUse = network_interface_in_use($interface); + $inUse = NetworkInterfaceInUse($interface); if ($inUse["mode"] === "AP") { return json(array( @@ -304,7 +307,7 @@ function network_wifi_scan() "message" => "$interface is currently $ssidMsg. This adapter doesn't support a safe scan while connected, so scanning is blocked to avoid dropping that connection." )); } - $networks = network_parse_iw_scan($output); + $networks = NetworkParseIwScan($output); return json(array("status" => "OK", "networks" => $networks)); } @@ -312,7 +315,7 @@ function network_wifi_scan() // and Wi-Fi 7 hardware where the old wireless extensions are gone. $output = array(); exec("sudo /sbin/iw dev $interface scan", $output); - $networks = network_parse_iw_scan($output); + $networks = NetworkParseIwScan($output); // Fallback: some out-of-tree Realtek USB drivers (e.g. RTL8812BU / // RTL8822BU on the 88x2bu driver) advertise no working nl80211 scan @@ -322,7 +325,7 @@ function network_wifi_scan() if (count($networks) == 0) { $output = array(); exec("sudo /sbin/iwlist $interface scan 2>/dev/null", $output); - $networks = network_parse_iwlist_scan($output); + $networks = NetworkParseIwlistScan($output); } return json(array("status" => "OK", "networks" => $networks)); @@ -341,13 +344,13 @@ function network_wifi_scan() * {"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)."} * ``` */ -function network_wifi_status() +function NetworkWiFiStatus() { global $settings; $interface = params('interface'); # Validate interface. -- Important because of SUDO - $interfaces = json_decode(network_list_interfaces(), true); + $interfaces = json_decode(NetworkListInterfaces(), true); $found = false; foreach ($interfaces as $row) { if ($row["ifname"] == $interface) { @@ -468,13 +471,14 @@ function network_wifi_status() * 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"} * ``` */ -function network_persistentNames_delete() +function NetworkPersistentNamesDelete() { global $settings; @@ -519,16 +523,17 @@ function network_persistentNames_delete() * 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} * ``` */ -function network_persistentNames_create() +function NetworkPersistentNamesCreate() { global $settings; - network_persistentNames_delete(); + NetworkPersistentNamesDelete(); $interfaces = network_list_interfaces_array(); $count = 0; @@ -578,7 +583,8 @@ function network_persistentNames_create() * 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 * { @@ -588,7 +594,7 @@ function network_persistentNames_create() * } * ``` */ -function network_get_dns() +function NetworkGetDNS() { global $settings; @@ -606,7 +612,8 @@ function network_get_dns() * * 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 @@ -619,7 +626,7 @@ function network_get_dns() * } * ``` */ -function network_save_dns() +function NetworkSaveDNS() { global $settings; @@ -653,13 +660,14 @@ function network_save_dns() * 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"} * ``` */ -function network_get_gateway() +function NetworkGetGateway() { global $settings; @@ -687,14 +695,15 @@ function network_get_gateway() * * 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 * {"status": "OK", "GATEWAY": "192.168.1.1"} * ``` */ -function network_save_gateway() +function NetworkSaveGateway() { global $settings; @@ -725,7 +734,8 @@ function network_save_gateway() * * 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 * { @@ -739,7 +749,7 @@ function network_save_gateway() * } * ``` */ -function network_get_interface() +function NetworkGetInterface() { global $settings; @@ -864,13 +874,15 @@ function network_get_interface() * Creates a new blank DHCP interface configuration file for the specified * network interface (e.g. `eth1`, `wlan0`). * - * @route GET /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"} * ``` */ -function network_add_interface() +function NetworkAddInterface() { global $settings; @@ -901,14 +913,15 @@ function network_add_interface() * 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 * {"status": "OK"} * ``` */ -function network_set_interface() +function NetworkSetInterface() { global $settings; @@ -1013,13 +1026,14 @@ function network_set_interface() * 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": []} * ``` */ -function network_apply_interface() +function NetworkApplyInterface() { global $settings, $SUDO; 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.php b/www/api/controllers/pipewire.php index 72b3d04d4..674814514 100644 --- a/www/api/controllers/pipewire.php +++ b/www/api/controllers/pipewire.php @@ -22,7 +22,7 @@ // Returns array('wasPlaying' => bool, 'playlist' => string, 'repeat' => bool) // Uses stream context timeout so PHP doesn't hang if fppd's HTTP handler // blocks during GStreamer pipeline teardown. -function StopFppdPlaybackSafe($timeoutSec = 3) +function stopFppdPlaybackSafe($timeoutSec = 3) { $result = array('wasPlaying' => false, 'playlist' => '', 'repeat' => false); @@ -54,7 +54,7 @@ function StopFppdPlaybackSafe($timeoutSec = 3) // Helper: Send a setting to fppd, tolerating a non-responsive daemon. // Writes to the settings file (always works) then best-effort sends via // the command socket (1-second timeout built into SendCommand). -function SetFppdSetting($key, $value) +function setFppdSetting($key, $value) { WriteSettingToFile($key, $value); // SendCommand may fail if fppd is deadlocked/restarting — that's OK @@ -230,7 +230,7 @@ function ApplyPipeWireAudioGroups($overrideData = null, $skipRestart = false) } // Generate PipeWire config - $genResult = GeneratePipeWireGroupsConfig($data['groups'], true); + $genResult = generatePipeWireGroupsConfig($data['groups'], true); $conf = $genResult['conf']; $resolvedCardMap = $genResult['cardNodeMap']; @@ -282,7 +282,7 @@ function ApplyPipeWireAudioGroups($overrideData = null, $skipRestart = false) if (file_exists($igFile)) { $igData = json_decode(file_get_contents($igFile), true); if (is_array($igData) && isset($igData['inputGroups']) && !empty($igData['inputGroups'])) { - $igConf = GeneratePipeWireInputGroupsConfig($igData['inputGroups'], $data['groups']); + $igConf = generatePipeWireInputGroupsConfig($igData['inputGroups'], $data['groups']); $igConfPath = "/etc/pipewire/pipewire.conf.d/96-fpp-input-groups.conf"; $igTmpFile = tempnam(sys_get_temp_dir(), 'fpp_pw_ig_'); file_put_contents($igTmpFile, $igConf); @@ -310,7 +310,7 @@ function ApplyPipeWireAudioGroups($overrideData = null, $skipRestart = false) // Without this, combine-stream output nodes can get linked to the default // sink (e.g. Sound Blaster) in addition to their intended filter-chain // targets, causing doubled audio. - InstallWirePlumberFppLinkingHook($SUDO); + installWirePlumberFppLinkingHook($SUDO); // When called with $skipRestart=true (e.g. from a MediaBackend mode switch), // config files are already written; the caller backgrounds the service restarts @@ -344,7 +344,7 @@ function ApplyPipeWireAudioGroups($overrideData = null, $skipRestart = false) // where WirePlumber creates rogue links to orphaned streams during the // service restart window. Uses timeout to prevent deadlock if fppd's // GStreamer teardown blocks on PipeWire. - $playbackState = StopFppdPlaybackSafe(3); + $playbackState = stopFppdPlaybackSafe(3); $wasPlaying = $playbackState['wasPlaying']; $resumePlaylist = $playbackState['playlist']; $resumeRepeat = $playbackState['repeat']; @@ -482,24 +482,24 @@ function ApplyPipeWireAudioGroups($overrideData = null, $skipRestart = false) $fppdTarget = isset($igSlotTargets[1]) ? $igSlotTargets[1] : ''; if (!empty($fppdTarget)) { exec($SUDO . " " . $env . " pactl set-default-sink " . escapeshellarg($fppdTarget) . " 2>&1"); - SetFppdSetting('PipeWireSinkName', $fppdTarget); + setFppdSetting('PipeWireSinkName', $fppdTarget); } for ($s = 2; $s <= 5; $s++) { $key = "PipeWireSinkName_$s"; if (isset($igSlotTargets[$s])) { - SetFppdSetting($key, $igSlotTargets[$s]); + setFppdSetting($key, $igSlotTargets[$s]); } } } else { if (!empty($activeGroup)) { exec($SUDO . " " . $env . " pactl set-default-sink " . escapeshellarg($activeGroup) . " 2>&1"); - SetFppdSetting('PipeWireSinkName', $activeGroup); + setFppdSetting('PipeWireSinkName', $activeGroup); } } // Restore configured volume levels to PipeWire sinks. // WirePlumber may have restored stale volume state after restart. - RestorePipeWireGroupVolumes($data['groups']); + restorePipeWireGroupVolumes($data['groups']); // Resume playback if it was active before the restart if ($wasPlaying && !empty($resumePlaylist)) { @@ -553,7 +553,7 @@ function GetPipeWireSinks() // Helper: Resolve an ALSA card ID (e.g. "S3", "vc4hdmi0") to its current // card number by reading /proc/asound/ symlink. // Returns the card number as int, or -1 if not found. -function ResolveCardIdToNumber($cardId) +function resolveCardIdToNumber($cardId) { $symlink = "/proc/asound/" . $cardId; if (is_link($symlink)) { @@ -585,7 +585,7 @@ function ResolveCardIdToNumber($cardId) // RATE: [8000 192000] (continuous range) // // Returns $fallbackRate if the device cannot be queried. -function QueryAlsaCardBestRate($cardId, $allowedRates, $fallbackRate) +function queryAlsaCardBestRate($cardId, $allowedRates, $fallbackRate) { // Only alphanumeric + underscore card IDs are safe as hw: path components. if (!preg_match('/^[a-zA-Z0-9_]+$/', $cardId)) { @@ -728,7 +728,7 @@ function GetPipeWireAudioCards() $cards = array(); // User-defined sound card aliases (issue #2586) keyed by ALSA card ID - $audioCardAliases = LoadAudioCardAliases(); + $audioCardAliases = loadAudioCardAliases(); // Query running PipeWire sinks to map to actual node names $pwSinkNames = array(); // substring -> full node name @@ -869,7 +869,7 @@ function GetPipeWireAudioCards() // Resolve card number from api.alsa.path (e.g. "hw:S3" → card 5) $fppAlsaPath = isset($pwProps['api.alsa.path']) ? $pwProps['api.alsa.path'] : ''; if (preg_match('/^hw:(.+)$/', $fppAlsaPath, $hwM)) { - $fppCardNum = ResolveCardIdToNumber(trim($hwM[1])); + $fppCardNum = resolveCardIdToNumber(trim($hwM[1])); if ($fppCardNum >= 0) { $pwSinkByAlsaCardNum[$fppCardNum] = $pwName; } @@ -1299,7 +1299,7 @@ function UpdatePipeWireEQRealtime() return json(array("status" => "ERROR", "message" => "Missing cardId or bands")); } - $nodeId = FindFXFilterChainNodeId($groupId, $cardId); + $nodeId = findFXFilterChainNodeId($groupId, $cardId); if ($nodeId === null) { // Filter-chain not running — needs Apply first @@ -1356,7 +1356,7 @@ function UpdatePipeWireDelayRealtime() return json(array("status" => "ERROR", "message" => "Missing cardId")); } - $nodeId = FindFXFilterChainNodeId($groupId, $cardId); + $nodeId = findFXFilterChainNodeId($groupId, $cardId); if ($nodeId === null) { return json(array("status" => "NOT_RUNNING", "message" => "Filter chain not active — Save & Apply first")); @@ -1387,7 +1387,7 @@ function UpdatePipeWireDelayRealtime() ///////////////////////////////////////////////////////////////////////////// // Helper: resolve the PipeWire combine-sink node name for an audio group // by index (as ordered in pipewire-audio-groups.json). -function GetSyncCalibrationSinkForGroup($groupIndex) +function getSyncCalibrationSinkForGroup($groupIndex) { global $settings; @@ -1423,13 +1423,13 @@ function StartSyncCalibration() $groupIndex = isset($data['groupIndex']) ? intval($data['groupIndex']) : 0; $mediaFile = isset($data['mediaFile']) ? trim($data['mediaFile']) : ''; - $sinkName = GetSyncCalibrationSinkForGroup($groupIndex); + $sinkName = getSyncCalibrationSinkForGroup($groupIndex); if (!$sinkName) { return json(array("status" => "ERROR", "message" => "Could not resolve target sink for group index " . $groupIndex . " — make sure the group has been saved/applied.")); } // Stop any existing calibration playback first - StopSyncCalibrationInternal(); + stopSyncCalibrationInternal(); // If the user picked a media file, play that to the group sink; otherwise // generate (if needed) and loop the click track. @@ -1440,7 +1440,7 @@ function StartSyncCalibration() if ($resolved === false || strpos($resolved, realpath($musicDir)) !== 0 || !is_file($resolved)) { return json(array("status" => "ERROR", "message" => "Media file not found: " . $mediaFile)); } - return StartSyncCalibrationPlayback($sinkName, $resolved, false); + return startSyncCalibrationPlayback($sinkName, $resolved, false); } $clickFile = $settings['mediaDirectory'] . "/music/fpp_sync_click.wav"; @@ -1453,7 +1453,7 @@ function StartSyncCalibration() } } - return StartSyncCalibrationPlayback($sinkName, $clickFile, true); + return startSyncCalibrationPlayback($sinkName, $clickFile, true); } // Synthesize the sync calibration click track as a 16-bit mono 44100 Hz WAV. @@ -1558,11 +1558,11 @@ function StartSyncCalibrationPlayback($sinkName, $absFile, $loop) // Stop sync calibration playback function StopSyncCalibration() { - StopSyncCalibrationInternal(); + stopSyncCalibrationInternal(); return json(array("status" => "OK", "message" => "Sync calibration stopped")); } -function StopSyncCalibrationInternal() +function stopSyncCalibrationInternal() { global $SUDO; @@ -1600,7 +1600,7 @@ function StopSyncCalibrationInternal() ///////////////////////////////////////////////////////////////////////////// // Helper: Find the PipeWire node ID for a member's filter-chain // Looks for "fpp_fx_g_" first, falls back to legacy "fpp_eq_g_" -function FindFXFilterChainNodeId($groupId, $cardId) +function findFXFilterChainNodeId($groupId, $cardId) { global $SUDO; @@ -1636,7 +1636,7 @@ function FindFXFilterChainNodeId($groupId, $cardId) // Without this hook, WirePlumber may create rogue links from combine outputs // to the default ALSA sink (e.g. Sound Blaster), causing doubled audio, and // may link filter-chain outputs back to the combine sink, creating loops. -function InstallWirePlumberFppLinkingHook($SUDO) +function installWirePlumberFppLinkingHook($SUDO) { // The hook is shipped as static files in the repo (/opt/fpp/etc) and copied // into place at image build; this just (re)deploys them into WirePlumber's @@ -1788,7 +1788,7 @@ function ApplyPipeWireInputGroups($skipRestart = false) } // Generate PipeWire config - $conf = GeneratePipeWireInputGroupsConfig($data['inputGroups'], $outputGroups); + $conf = generatePipeWireInputGroupsConfig($data['inputGroups'], $outputGroups); // Ensure directory exists exec($SUDO . " /bin/mkdir -p /etc/pipewire/pipewire.conf.d"); @@ -1804,10 +1804,10 @@ function ApplyPipeWireInputGroups($skipRestart = false) file_put_contents($cachedConf, $conf); // Update WirePlumber hook to include input group patterns - InstallWirePlumberFppLinkingHook($SUDO); + installWirePlumberFppLinkingHook($SUDO); // Stop fppd playback before restarting PipeWire (with timeout protection) - $playbackState = StopFppdPlaybackSafe(3); + $playbackState = stopFppdPlaybackSafe(3); $wasPlaying = $playbackState['wasPlaying']; $resumePlaylist = $playbackState['playlist']; $resumeRepeat = $playbackState['repeat']; @@ -1890,14 +1890,14 @@ function ApplyPipeWireInputGroups($skipRestart = false) if (!empty($fppdTarget)) { $env = "PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp XDG_RUNTIME_DIR=/run/pipewire-fpp PULSE_RUNTIME_PATH=/run/pipewire-fpp/pulse"; exec($SUDO . " " . $env . " pactl set-default-sink " . escapeshellarg($fppdTarget) . " 2>&1"); - SetFppdSetting('PipeWireSinkName', $fppdTarget); + setFppdSetting('PipeWireSinkName', $fppdTarget); } for ($s = 2; $s <= 5; $s++) { $key = "PipeWireSinkName_$s"; if (isset($slotTargets[$s])) { - SetFppdSetting($key, $slotTargets[$s]); + setFppdSetting($key, $slotTargets[$s]); } else { - SetFppdSetting($key, ''); + setFppdSetting($key, ''); } } @@ -1969,7 +1969,7 @@ function SetInputGroupMemberVolume() $mbr = $targetGroup['members'][$memberIndex]; $mbrType = isset($mbr['type']) ? $mbr['type'] : ''; - // Build the expected loopback node name (must match GeneratePipeWireInputGroupsConfig) + // Build the expected loopback node name (must match generatePipeWireInputGroupsConfig) $groupName = isset($targetGroup['name']) ? $targetGroup['name'] : "Input Group"; $mbrName = isset($mbr['name']) ? $mbr['name'] : "Member $memberIndex"; @@ -3324,7 +3324,7 @@ function GetPipeWirePluginSources() ///////////////////////////////////////////////////////////////////////////// // Helper: Resolve ALSA card ID to exact PipeWire capture node name // Queries pw-dump to find the Audio/Source node matching the given card ID. -function ResolveAlsaCaptureNodeName($cardId) +function resolveAlsaCaptureNodeName($cardId) { global $SUDO; @@ -3366,7 +3366,7 @@ function ResolveAlsaCaptureNodeName($cardId) ///////////////////////////////////////////////////////////////////////////// // Helper: Generate PipeWire input group config (combine-stream + loopback) -function GeneratePipeWireInputGroupsConfig($inputGroups, $outputGroups) +function generatePipeWireInputGroupsConfig($inputGroups, $outputGroups) { global $settings; $channelPositions = array( @@ -3755,7 +3755,7 @@ function GeneratePipeWireInputGroupsConfig($inputGroups, $outputGroups) $sourceTarget = $mbr['nodeName']; } else { // Resolve from pw-dump at config generation time - $sourceTarget = ResolveAlsaCaptureNodeName($cardId); + $sourceTarget = resolveAlsaCaptureNodeName($cardId); if (empty($sourceTarget)) continue; } @@ -3886,7 +3886,7 @@ function GroupsAreDummyOnly($groups) ///////////////////////////////////////////////////////////////////////////// // Helper: Generate PipeWire combine-stream config from groups -function GeneratePipeWireGroupsConfig($groups, $returnCardMap = false) +function generatePipeWireGroupsConfig($groups, $returnCardMap = false) { global $SUDO, $settings; @@ -4003,7 +4003,7 @@ function GeneratePipeWireGroupsConfig($groups, $returnCardMap = false) $cn = intval($dev); } else { // Stable card ID — resolve via /proc/asound - $cn = ResolveCardIdToNumber($dev); + $cn = resolveCardIdToNumber($dev); } } } @@ -4123,7 +4123,7 @@ function GeneratePipeWireGroupsConfig($groups, $returnCardMap = false) } // Priority 3: cardId → card number → node name - $cardNum = ResolveCardIdToNumber($cardId); + $cardNum = resolveCardIdToNumber($cardId); if ($cardNum >= 0 && isset($sinkCardNumMap[$cardNum])) { $cardNodeMap[$cardId] = $sinkCardNumMap[$cardNum]; continue; @@ -4137,7 +4137,7 @@ function GeneratePipeWireGroupsConfig($groups, $returnCardMap = false) // crashes fatally trying to open a missing ALSA device). if (isset($member['nodeTarget']) && !empty($member['nodeTarget'])) { if (strpos($member['nodeTarget'], 'fpp_alsa_') === 0) { - $p4CardNum = ResolveCardIdToNumber($cardId); + $p4CardNum = resolveCardIdToNumber($cardId); if ($p4CardNum < 0) { $unresolvedCards[] = $cardId . " (device unplugged — will be restored when reconnected)"; continue; @@ -4297,7 +4297,7 @@ function GeneratePipeWireGroupsConfig($groups, $returnCardMap = false) if (!$needsCustom) continue; // Verify ALSA device is still present (may have been unplugged) - if (ResolveCardIdToNumber($cid) < 0) + if (resolveCardIdToNumber($cid) < 0) continue; // Track max channels needed per card (same card in multiple groups) if (!isset($customAlsaAdapters[$cid]) || $memberCh > $customAlsaAdapters[$cid]['channels']) { @@ -4386,7 +4386,7 @@ function GeneratePipeWireGroupsConfig($groups, $returnCardMap = false) foreach ($customAlsaAdapters as $cid => $info) { // Verify ALSA device is physically present before creating adapter. // A missing device causes PipeWire to crash fatally on startup. - if (ResolveCardIdToNumber($cid) < 0) { + if (resolveCardIdToNumber($cid) < 0) { $conf .= " # SKIPPED: $cid — ALSA card not present (device unplugged?)\n"; unset($cardNodeMap[$cid]); continue; @@ -4398,7 +4398,7 @@ function GeneratePipeWireGroupsConfig($groups, $returnCardMap = false) $conf .= " factory.name = api.alsa.pcm.sink\n"; $conf .= " node.name = \"" . $info['nodeName'] . "\"\n"; // Read USB product name for consistent description - $cardNumForDesc = ResolveCardIdToNumber($cid); + $cardNumForDesc = resolveCardIdToNumber($cid); $productNameForDesc = $cid; if ($cardNumForDesc >= 0) { $sysfsProduct = @file_get_contents("/sys/class/sound/card$cardNumForDesc/device/product"); @@ -4418,7 +4418,7 @@ function GeneratePipeWireGroupsConfig($groups, $returnCardMap = false) $conf .= " api.alsa.period-size = $adapterPeriod\n"; // USB audio cards need extra headroom: their independent oscillators // drift relative to the PipeWire graph driver clock, causing resyncs. - $cardNum = ResolveCardIdToNumber($cid); + $cardNum = resolveCardIdToNumber($cid); $isUsb = false; if ($cardNum >= 0) { $driverLink = @readlink("/sys/class/sound/card$cardNum/device/driver"); @@ -4434,7 +4434,7 @@ function GeneratePipeWireGroupsConfig($groups, $returnCardMap = false) if (isset($sinkCardRateMap[$cid])) { $adapterRate = $sinkCardRateMap[$cid]; } else { - $adapterRate = QueryAlsaCardBestRate($cid, $allowedRates, $alsaRate); + $adapterRate = queryAlsaCardBestRate($cid, $allowedRates, $alsaRate); } } $conf .= " audio.rate = " . $adapterRate . "\n"; @@ -4757,10 +4757,10 @@ function GeneratePipeWireGroupsConfig($groups, $returnCardMap = false) } ///////////////////////////////////////////////////////////////////////////// -// RestorePipeWireGroupVolumes +// restorePipeWireGroupVolumes // Apply per-group and per-member volume levels from the audio groups JSON // to the running PipeWire sinks via pactl. Call after PipeWire restart. -function RestorePipeWireGroupVolumes($groups = null) +function restorePipeWireGroupVolumes($groups = null) { global $SUDO, $settings; @@ -5979,7 +5979,7 @@ function GetPipeWireGraph() ///////////////////////////////////////////////////////////////////////////// // Helper: Enumerate available DRM/KMS video connectors via sysfs. // Returns array of { connector, card, connectorId, connected, width, height } -function GetVideoConnectors() +function getVideoConnectors() { $connectors = array(); $drmDir = '/sys/class/drm'; @@ -6049,7 +6049,7 @@ function GetVideoConnectors() ///////////////////////////////////////////////////////////////////////////// // Helper: Enumerate available PixelOverlay models for video output. -function GetVideoOverlayModels() +function getVideoOverlayModels() { $models = array(); $ctx = stream_context_create(array('http' => array('timeout' => 2))); @@ -6083,8 +6083,8 @@ function GetVideoOverlayModels() function GetVideoOutputTargets() { return json(array( - 'connectors' => GetVideoConnectors(), - 'overlayModels' => GetVideoOverlayModels(), + 'connectors' => getVideoConnectors(), + 'overlayModels' => getVideoOverlayModels(), )); } @@ -6200,7 +6200,7 @@ function ApplyPipeWireVideoGroups($overrideData = null) } // Resolve hardware info once - $connectors = GetVideoConnectors(); + $connectors = getVideoConnectors(); $connectorMap = array(); foreach ($connectors as $c) { $connectorMap[$c['connector']] = $c; @@ -6329,7 +6329,7 @@ function ApplyPipeWireVideoGroups($overrideData = null) $key = ($s === 1) ? 'PipeWireVideoSinkName' : "PipeWireVideoSinkName_$s"; if ($anyGroupUnrestricted || isset($slotsNeeded[$s])) { WriteSettingToFile($key, $videoSinkName); - SetFppdSetting($key, $videoSinkName); + setFppdSetting($key, $videoSinkName); } else { WriteSettingToFile($key, ''); @SendCommand("setSetting,$key,"); @@ -6340,7 +6340,7 @@ function ApplyPipeWireVideoGroups($overrideData = null) } // Install / update WirePlumber hook (already has video patterns) - InstallWirePlumberFppLinkingHook($SUDO); + installWirePlumberFppLinkingHook($SUDO); // Signal fppd to reload video consumer config @SendCommand("reloadVideoOutputs"); @@ -6724,27 +6724,10 @@ function SaveVideoRoutingMatrix() // $mediaDirectory/config/pipewire-video-groups-simple.json ///////////////////////////////////////////////////////////////////////////// -///////////////////////////////////////////////////////////////////////////// -// Helper: Resolve an ALSA card number (e.g. "0", "1") to its stable -// ALSA card ID (read from /proc/asound/cardN/id). Used by Simple PipeWire -// mode to translate the legacy AudioOutput numeric setting into the cardId -// string consumed by PipeWire audio groups. -function ResolveAlsaCardNumberToId($cardNum) -{ - $cardNum = (string) intval($cardNum); - $idFile = "/proc/asound/card{$cardNum}/id"; - if (file_exists($idFile)) { - $id = trim(@file_get_contents($idFile)); - if ($id !== '') - return $id; - } - return ''; -} - ///////////////////////////////////////////////////////////////////////////// // Build a single-group audio data structure from the AudioOutput setting. // Returns an array shaped like the contents of pipewire-audio-groups.json. -function BuildSimpleAudioGroupsData($audioOutput) +function buildSimpleAudioGroupsData($audioOutput) { // $audioOutput is the persisted AudioOutput value (a stable ALSA card ID, // or a legacy numeric index). Normalize either form to a card ID. @@ -6780,7 +6763,7 @@ function BuildSimpleAudioGroupsData($audioOutput) // Returns an array shaped like pipewire-video-groups.json, or null if the // VideoOutput value does not map to a real DRM connector (Disabled, // --Default--, etc.) — in which case the video pipeline is skipped. -function BuildSimpleVideoGroupsData($videoOutput) +function buildSimpleVideoGroupsData($videoOutput) { if (empty($videoOutput) || $videoOutput === 'Disabled' || $videoOutput === '--Default--') { return array("videoOutputGroups" => array()); @@ -6788,7 +6771,7 @@ function BuildSimpleVideoGroupsData($videoOutput) // Validate the connector exists; bail out if not (e.g. Composite-1 on // a board without that connector). - $connectors = GetVideoConnectors(); + $connectors = getVideoConnectors(); $found = false; foreach ($connectors as $c) { if ($c['connector'] === $videoOutput) { @@ -6833,8 +6816,8 @@ function ApplyPipeWireSimpleConfig($skipRestart = false) $audioOutput = isset($settings['AudioOutput']) ? $settings['AudioOutput'] : '0'; $videoOutput = isset($settings['VideoOutput']) ? $settings['VideoOutput'] : ''; - $audioData = BuildSimpleAudioGroupsData($audioOutput); - $videoData = BuildSimpleVideoGroupsData($videoOutput); + $audioData = buildSimpleAudioGroupsData($audioOutput); + $videoData = buildSimpleVideoGroupsData($videoOutput); // Persist a record of the synthesised config so the boot-time apply // (and any future debugging) can see what Simple mode produced. 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 55d1edd76..9379100ba 100644 --- a/www/api/controllers/playlist.php +++ b/www/api/controllers/playlist.php @@ -5,13 +5,14 @@ * * 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"] * ``` */ -function playlist_list() +function PlaylistList() { global $settings; $playlists = array(); @@ -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 * [ @@ -186,7 +188,7 @@ function validatePlayListEntries(&$entries, &$media, &$playlist, &$rc) * ] * ``` */ -function playlist_list_validate() +function PlaylistListValidate() { global $settings; $mediaFiles = loadValidateFiles(); @@ -204,7 +206,7 @@ function playlist_list_validate() $rc = array(); foreach ($playlists as $plName) { - $pl = LoadPlayListDetails($plName, false); + $pl = loadPlayListDetails($plName, false); $valid = true; $msg = []; if (isset($pl->leadIn)) { @@ -268,13 +270,14 @@ function playlist_list_validate() * * 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"] * ``` */ -function playlist_playable() +function PlaylistPlayable() { global $settings; $playlists = array(); @@ -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 @@ -364,7 +368,7 @@ function cleanMedialNamesInPlaylist(&$playlistObj, $section) * } * ``` */ -function playlist_insert() +function PlaylistInsert() { global $settings; @@ -401,9 +405,9 @@ function playlist_insert() * @param object $plentry Playlist entry object containing the sub-playlist name. * @return void */ -function LoadSubPlaylist(&$playlist, &$i, $plentry) +function loadSubPlaylist(&$playlist, &$i, $plentry) { - $data = GetPlaylist($plentry->name); + $data = getPlaylist($plentry->name); $subPlaylist = array(); @@ -422,7 +426,7 @@ function LoadSubPlaylist(&$playlist, &$i, $plentry) $li = 0; foreach ($subPlaylist as $entry) { if ($entry->type == "playlist") { - LoadSubPlaylist($subPlaylist, $li, $entry); + loadSubPlaylist($subPlaylist, $li, $entry); } $li++; @@ -441,7 +445,7 @@ function LoadSubPlaylist(&$playlist, &$i, $plentry) * @param bool $mergeSubs When true, recursively merges sub-playlists into parent sections. * @return object|string Decoded playlist object, or empty string if the file does not exist. */ -function LoadPlayListDetails($file, $mergeSubs) +function loadPlayListDetails($file, $mergeSubs) { global $settings; @@ -456,7 +460,7 @@ function LoadPlayListDetails($file, $mergeSubs) return ""; } - $data = GetPlaylist($file); + $data = getPlaylist($file); if (!$mergeSubs) { return $data; @@ -466,7 +470,7 @@ function LoadPlayListDetails($file, $mergeSubs) $i = 0; foreach ($data->leadIn as $entry) { if ($mergeSubs && $entry->type == "playlist") { - LoadSubPlaylist($data->leadIn, $i, $entry); + loadSubPlaylist($data->leadIn, $i, $entry); } $i++; } @@ -476,7 +480,7 @@ function LoadPlayListDetails($file, $mergeSubs) $i = 0; foreach ($data->mainPlaylist as $entry) { if ($mergeSubs && $entry->type == "playlist") { - LoadSubPlaylist($data->mainPlaylist, $i, $entry); + loadSubPlaylist($data->mainPlaylist, $i, $entry); } $i++; } @@ -486,7 +490,7 @@ function LoadPlayListDetails($file, $mergeSubs) $i = 0; foreach ($data->leadOut as $entry) { if ($mergeSubs && $entry->type == "playlist") { - LoadSubPlaylist($data->leadOut, $i, $entry); + loadSubPlaylist($data->leadOut, $i, $entry); } $i++; } @@ -501,7 +505,7 @@ function LoadPlayListDetails($file, $mergeSubs) * @param string $playlistName Playlist name (without .json extension). * @return object Decoded playlist object. */ -function GetPlaylist($playlistName) +function getPlaylist($playlistName) { global $settings; @@ -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 @@ -534,7 +539,7 @@ function GetPlaylist($playlistName) * } * ``` */ -function playlist_get() +function PlaylistGet() { global $settings; @@ -544,7 +549,7 @@ function playlist_get() $mergeSubs = 1; } - $data = LoadPlayListDetails($playlistName, $mergeSubs); + $data = loadPlayListDetails($playlistName, $mergeSubs); return json($data); } @@ -554,7 +559,8 @@ function playlist_get() * * 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 @@ -571,7 +577,7 @@ function playlist_get() * } * ``` */ -function playlist_update() +function PlaylistUpdate() { global $settings; @@ -637,13 +643,14 @@ function playlist_update() * * 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": ""} * ``` */ -function playlist_delete() +function PlaylistDelete() { global $settings; @@ -676,7 +683,8 @@ function playlist_delete() * * 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,13 +744,15 @@ function PlaylistSectionInsertItem() * Immediately stop the currently running playlist. * * @badge "FPP REQUIRED" critical - * @route GET /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": ""} * ``` */ -function playlist_stop() +function PlaylistStop() { global $settings; $curl = curl_init(); @@ -761,13 +771,15 @@ function playlist_stop() * Gracefully stop the currently running playlist. * * @badge "FPP REQUIRED" critical - * @route GET /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": ""} * ``` */ -function playlist_stopgracefully() +function PlaylistStopGracefully() { global $settings; @@ -787,13 +799,15 @@ function playlist_stopgracefully() * current loop. * * @badge "FPP REQUIRED" critical - * @route GET /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": ""} * ``` */ -function playlist_stopgracefullyafterloop() +function PlaylistStopGracefullyAfterLoop() { global $settings; @@ -814,13 +828,15 @@ function playlist_stopgracefullyafterloop() * this playlist. * * @badge "FPP REQUIRED" critical - * @route GET /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": ""} * ``` */ -function playlist_start() +function PlaylistStart() { global $settings; @@ -844,14 +860,16 @@ function playlist_start() * scheduler from stopping this playlist. * * @badge "FPP REQUIRED" critical - * @route GET /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 * {"Status": "OK", "Message": ""} * ``` */ -function playlist_start_repeat() +function PlaylistStartRepeat() { global $settings; @@ -876,13 +894,15 @@ function playlist_start_repeat() * stop this playlist. * * @badge "FPP REQUIRED" critical - * @route GET /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": ""} * ``` */ -function playlist_start_repeat_protected() +function PlaylistStartRepeatProtected() { global $settings; @@ -905,13 +925,15 @@ function playlist_start_repeat_protected() * Pause the currently running playlist. * * @badge "FPP REQUIRED" critical - * @route GET /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": ""} * ``` */ -function playlist_pause() +function PlaylistPause() { global $settings; @@ -930,13 +952,15 @@ function playlist_pause() * Resume a previously paused playlist. * * @badge "FPP REQUIRED" critical - * @route GET /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": ""} * ``` */ -function playlist_resume() +function PlaylistResume() { global $settings; diff --git a/www/api/controllers/plugin.php b/www/api/controllers/plugin.php index 7d075eb28..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 * { @@ -749,7 +752,7 @@ function GetPluginInfo() $json = file_get_contents($infoFile); $result = json_decode($json, true); $result['Status'] = 'OK'; - $result['updatesAvailable'] = PluginHasUpdates($plugin); + $result['updatesAvailable'] = pluginHasUpdates($plugin); $iconFile = $settings['pluginDirectory'] . '/' . $plugin . '/icon.png'; $result['hasIcon'] = file_exists($iconFile) || !empty($result['iconURL']); @@ -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} @@ -925,7 +930,7 @@ function CheckForPluginUpdates() if ($return_val == 0) { $result['Status'] = 'OK'; $result['Message'] = ''; - $result['updatesAvailable'] = PluginHasUpdates($plugin); + $result['updatesAvailable'] = pluginHasUpdates($plugin); } else { $result['Status'] = 'Error'; $result['Message'] = 'Could not run git fetch for plugin ' . $plugin; @@ -940,8 +945,10 @@ function CheckForPluginUpdates() * Pull in git updates for plugin `{RepoName}`. Supports an optional * `?stream=true` query parameter for streaming output. * - * @route GET /api/plugin/{RepoName}/upgrade - * @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 * {"Status": "OK", "Message": ""} @@ -997,7 +1004,7 @@ function UpgradePlugin() * @return string|false Modified URL on success, or false if credentials are not * configured or the URL is not a recognized GitHub URL. */ -function InjectGitHubCredentials($url) +function injectGitHubCredentials($url) { global $settings; @@ -1026,7 +1033,7 @@ function InjectGitHubCredentials($url) * @param string $url URL to fetch. * @return string|false Response body on success, or false on failure. */ -function FetchURLWithGitHubCredentials($url) +function fetchURLWithGitHubCredentials($url) { global $GitHubFetchLastError; $GitHubFetchLastError = ''; @@ -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 @@ -1583,7 +1591,7 @@ function FetchPluginInfoProxy() if ($user === '' || $pat === '') { return json(array('Status' => 'Error', 'Message' => 'GitHub user name and/or Personal Access Token are not configured on the Developer settings page.')); } - $data = FetchURLWithGitHubCredentials($url); + $data = fetchURLWithGitHubCredentials($url); } else { $data = file_get_contents($url); } @@ -1619,7 +1627,7 @@ function FetchPluginInfoProxy() * @param string $plugin Plugin directory name (repo name). * @return int 1 if updates are available, 0 otherwise. */ -function PluginHasUpdates($plugin) +function pluginHasUpdates($plugin) { global $settings, $fppDir; $output = ''; @@ -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 @@ -1950,4 +1961,4 @@ function _PluginExtractPageFromRaw($src, $unified, $type, array &$pages) } } -?> \ No newline at end of file +?> 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 ac0b3fdde..5ce0ba04f 100644 --- a/www/api/controllers/proxies.php +++ b/www/api/controllers/proxies.php @@ -2,22 +2,85 @@ require_once(__DIR__ . "/../../config.php"); /** - * Proxy a command to remote FPP + * Proxy a command to remote FPP (v1 — query parameters) * * Proxies a named action to a remote FPP instance by IP address. * Supported actions: `listUpgrades`, `reboot`, `restartFppd`, `upgradeOS`. * - * @route GET /api/remoteAction + * @route-v1 POST /remoteAction + * @body {"ip": "192.168.1.100", "action": "reboot"} * @response 400 Invalid action * ```json * {"error": "Invalid action given: badaction"} * ``` */ -function remoteAction() +function RemoteAction_v1() { global $settings; - $ip = htmlspecialchars(isset($_GET['ip']) ? $_GET['ip'] : null); - $action = htmlspecialchars(isset($_GET['action']) ? $_GET['action'] : null); + $body = get_json_body(); + $ip = htmlspecialchars(isset($body['ip']) ? $body['ip'] : ''); + $action = htmlspecialchars(isset($body['action']) ? $body['action'] : ''); + + $action_map = [ + 'listUpgrades' => '/api/git/releases/os', + 'reboot' => '/api/system/reboot', + 'restartFppd' => '/api/system/fppd/restart', + 'upgradeOS' => '/upgradeOS', + ]; + + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + http_response_code(400); + echo json_encode(['error' => "Invalid IP address: $ip"]); + exit(0); + } + + if (!array_key_exists($action, $action_map)) { + http_response_code(400); + return json(['error' => 'HTTP Error: 400', 'details' => "Invalid action given: $action"]); + } + + $curl = curl_init('http://' . $ip . $action_map[$action]); + curl_setopt($curl, CURLOPT_FAILONERROR, true); + curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT_MS, 2000); + $request_content = curl_exec($curl); + $http_code = curl_getinfo($curl, CURLINFO_HTTP_CODE); + if ($http_code !== 200) { + curl_close($curl); + http_response_code($http_code); + $details = $request_content ? $request_content : 'No response content'; + if ($http_code < 200) { + $details = curl_error($curl); + } + http_response_code(400); + return json(['error' => 'HTTP Error: ' . $http_code, 'details' => $request_content]); + } + curl_close($curl); + + return($request_content); + +} + +/** + * Proxy a command to remote FPP (v2 — JSON body) + * + * Proxies a named action to a remote FPP instance by IP address. + * Supported actions: `listUpgrades`, `reboot`, `restartFppd`, `upgradeOS`. + * + * @route-v2 POST /remoteAction + * @body {"ip": "192.168.1.100", "action": "reboot"} + * @response 400 Invalid action + * ```json + * {"error": "Invalid action given: badaction"} + * ``` + */ +function RemoteAction() +{ + global $settings; + $body = getJsonBody(); + $ip = htmlspecialchars(isset($body['ip']) ? $body['ip'] : ''); + $action = htmlspecialchars(isset($body['action']) ? $body['action'] : ''); $action_map = [ 'listUpgrades' => '/api/git/releases/os', @@ -129,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 @@ -240,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 * [ @@ -261,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 * [ @@ -294,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 * [] @@ -329,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 * { @@ -421,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"} @@ -507,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 89e713db7..f1b716f47 100644 --- a/www/api/controllers/scripts.php +++ b/www/api/controllers/scripts.php @@ -5,13 +5,14 @@ * * 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"] * ``` */ -function scripts_list() +function ScriptsList() { global $settings; @@ -37,13 +38,14 @@ function scripts_list() * * 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 * ``` */ -function script_get() +function ScriptGet() { global $settings; @@ -61,7 +63,8 @@ function script_get() * * 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 * { @@ -71,7 +74,7 @@ function script_get() * } * ``` */ -function script_save() +function ScriptSave() { global $settings; $scriptName = params("scriptName"); @@ -113,13 +116,15 @@ function script_save() * * Runs a locally installed script. * - * @route GET /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 * ``` */ -function script_run() +function ScriptRun() { global $settings; @@ -138,13 +143,14 @@ function script_run() * * 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 * ``` */ -function scripts_view_remote() +function ScriptsViewRemote() { $category = params('category'); $filename = params('filename'); @@ -159,13 +165,15 @@ function scripts_view_remote() * * Installs a remote script from the script repository. * - * @route GET /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"} * ``` */ -function scripts_install_remote() +function ScriptsInstallRemote() { global $fppDir, $SUDO; global $scriptDirectory; diff --git a/www/api/controllers/sequence.php b/www/api/controllers/sequence.php index 48d490b33..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] @@ -47,7 +49,7 @@ function GetSequence() if ((substr($sequence, -5) != ".fseq") && (substr($sequence, -5) != ".eseq")) { $sequence = $sequence . ".fseq"; } - $dir = FSeqOrEseqDirectory($sequence); + $dir = fSeqOrEseqDirectory($sequence); $file = $dir . '/' . findFile($dir, $sequence); if (file_exists($file)) { if (ob_get_level()) { @@ -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 * { @@ -96,7 +99,7 @@ function GetSequenceMetaData() if ((substr($sequence, -5) != ".fseq") && (substr($sequence, -5) != ".eseq")) { $sequence = $sequence . ".fseq"; } - $dir = FSeqOrEseqDirectory($sequence); + $dir = fSeqOrEseqDirectory($sequence); $file = $dir . '/' . findFile($dir, $sequence); if (file_exists($file)) { $cmd = $fppDir . "/src/fsequtils -j " . escapeshellarg($file) . " 2> /dev/null"; @@ -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 @@ -138,7 +142,7 @@ function PostSequence() if ((substr($sequence, -5) != ".fseq") && (substr($sequence, -5) != ".eseq")) { $sequence = $sequence . ".fseq"; } - $dir = FSeqOrEseqDirectory($sequence); + $dir = fSeqOrEseqDirectory($sequence); $file = $dir . '/' . findFile($dir, $sequence); $putdata = fopen("php://input", "r"); @@ -161,20 +165,21 @@ 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": ""} * ``` */ -function DeleteSequences() +function DeleteSequence() { global $settings; $sequence = params('SequenceName'); if ((substr($sequence, -5) != ".fseq") && (substr($sequence, -5) != ".eseq")) { $sequence = $sequence . ".fseq"; } - $dir = FSeqOrEseqDirectory($sequence); + $dir = fSeqOrEseqDirectory($sequence); $file = $dir . '/' . findFile($dir, $sequence); if (file_exists($file)) { unlink($file); @@ -197,7 +202,9 @@ function DeleteSequences() * @badge "FPP REQUIRED" critical * @badge "DEVELOPER ONLY" info * - * @route GET /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 GET /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 GET /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 GET /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"} @@ -303,7 +316,7 @@ function GetSequenceStop() * @param string $seq Sequence filename including extension (`.fseq` or `.eseq`). * @return string Absolute path to the directory containing the sequence file. */ -function FSeqOrEseqDirectory($seq) +function fSeqOrEseqDirectory($seq) { global $settings; if (substr($seq, -5) == ".fseq") { diff --git a/www/api/controllers/settings.php b/www/api/controllers/settings.php index 0f42eeaef..ce8a6b779 100644 --- a/www/api/controllers/settings.php +++ b/www/api/controllers/settings.php @@ -5,7 +5,7 @@ * * @return array Associative array of settings metadata. */ -function ReadSettingsJSON() +function readSettingsJSON() { global $settings; $json = file_get_contents($settings['fppDir'] . '/www/settings.json'); @@ -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..17929dde4 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 @@ -80,7 +81,7 @@ function stats_generate($statsFile) * } * ``` */ -function stats_get_last_file() +function StatsGetLastFile() { global $_GET; $statsFile = stats_get_filename(); @@ -218,16 +219,17 @@ 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"} * ``` */ -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); @@ -248,13 +250,14 @@ 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"} * ``` */ -function stats_delete_last_file() +function StatsDeleteLastFile() { $statsFile = stats_get_filename(); if (file_exists($statsFile)) { diff --git a/www/api/controllers/system.php b/www/api/controllers/system.php index 5678427cd..e97ec8ecb 100644 --- a/www/api/controllers/system.php +++ b/www/api/controllers/system.php @@ -8,8 +8,10 @@ * * Reboots the operating system. * - * @route GET /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 GET /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 GET /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"} @@ -88,7 +94,7 @@ function StartFPPD() * Sends stop commands to `fppd` and kills the process if it does not exit cleanly. * Used internally by `StopFPPD()` and `RestartFPPD()`; returns no output. */ -function StopFPPDNoStatus() +function stopFPPDNoStatus() { global $SUDO, $settings; @@ -116,7 +122,9 @@ function StopFPPDNoStatus() * * Stops the `fppd` process if it is running. * - * @route GET /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"} @@ -124,7 +132,7 @@ function StopFPPDNoStatus() */ function StopFPPD() { - StopFPPDNoStatus(); + stopFPPDNoStatus(); $output = array("status" => "OK"); return json($output); } @@ -135,7 +143,10 @@ function StopFPPD() * Restarts the `fppd` process. Pass `?quick=1` to reload some configuration without * a full restart. * - * @route GET /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 * {"status": "OK"} @@ -153,7 +164,7 @@ function RestartFPPD() return json($output); } } - StopFPPDNoStatus(); + stopFPPDNoStatus(); return StartFPPD(); } @@ -162,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 * { @@ -200,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 * { @@ -415,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 @@ -447,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} @@ -488,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 * { @@ -544,7 +560,7 @@ function SystemGetStatus() 'time_remaining' => '00:00' ); - $default_return_json['uuid'] = stats_getUUID(); + $default_return_json['uuid'] = statsGetUUID(); //if the ip= argument supplied if (isset($_GET['ip'])) { @@ -699,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 * { @@ -823,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"] @@ -855,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 * { @@ -912,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..477bdaa01 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 * { @@ -22,7 +23,7 @@ * } * ``` */ -function testMode_Get() +function TestModeGet() { return json(json_decode(SendCommand("GetTestMode"))); } @@ -33,14 +34,15 @@ 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 * { "status": "OK" } * ``` */ -function testMode_Set() +function TestModeSet() { $json = strval(file_get_contents('php://input')); diff --git a/www/api/ht.access b/www/api/ht.access index 8158338a0..e5d98b43a 100644 --- a/www/api/ht.access +++ b/www/api/ht.access @@ -3,6 +3,13 @@ RewriteEngine on RewriteBase /api/ +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] + RewriteCond %{SCRIPT_FILENAME} !-f RewriteCond %{SCRIPT_FILENAME} !-d @@ -18,8 +25,7 @@ RewriteRule ^index.php - [L,NC] RewriteRule ^help ../api/ [R=301,L,NC] RewriteRule ^help/.* ../api/ [R=301,L,NC] RewriteRule ^endpoints.json - [L,NC] -RewriteRule ^openapi.json - [L,NC] -RewriteRule ^api.html - [L,NC] +RewriteRule ^v2/(.*)$ /api/index.php?uri=/v2/$1 [NC,L,QSA,B] RewriteRule ^(.*)$ /api/index.php?uri=/$1 [NC,L,QSA,B] Header add Access-Control-Allow-Origin "*" diff --git a/www/api/index.php b/www/api/index.php index bd1368e38..8dec73dc8 100644 --- a/www/api/index.php +++ b/www/api/index.php @@ -4,288 +4,372 @@ $skipJSsettings = 1; require_once '../config.php'; require_once '../common.php'; +require_once 'controllers/helpers.php'; -dispatch_get('/', 'ServeApiDocs'); -dispatch_get('/api.html', 'ServeApiHtml'); -dispatch_get('/openapi.yaml', 'ServeOpenApiSpec'); -dispatch_get('/openapi.json', 'ServeOpenApiSpec'); - -dispatch_get('/backups/list', 'GetAvailableBackups'); -dispatch_get('/backups/list/:DeviceName', 'GetAvailableBackupsOnDevice'); -dispatch_get('/backups/devices', 'RetrieveAvailableBackupsDevices'); -dispatch_post('/backups/devices/mount/:DeviceName/:MountLocation', 'MountDevice'); -dispatch_post('/backups/devices/unmount/:DeviceName/:MountLocation', 'UnmountDevice'); -dispatch_post('/backups/configuration', 'MakeJSONBackup'); -dispatch_get('/backups/configuration/list', 'GetAvailableJSONBackups'); -dispatch_get('/backups/configuration/list/:DeviceName', 'GetAvailableJSONBackupsOnDevice'); -dispatch_post('/backups/configuration/restore/:Directory/:BackupFilename', 'RestoreJsonBackup'); -dispatch_get('/backups/configuration/:Directory/:BackupFilename', 'DownloadJsonBackup'); -dispatch_delete('/backups/configuration/:Directory/:BackupFilename', 'DeleteJsonBackup'); - -dispatch_get('/cape', 'GetCapeInfo'); -dispatch_post('/cape/eeprom/voucher', 'RedeemVoucher'); -dispatch_post('/cape/eeprom/sign/:key/:order', 'SignEEPROM'); -dispatch_get('/cape/eeprom/signingData/:key/:order', 'GetSigningData'); -dispatch_get('/cape/eeprom/signingFile/:key/:order', 'GetSigningFile'); -dispatch_post('/cape/eeprom/signingData', 'PostSigningData'); -dispatch_get('/cape/options', 'GetCapeOptions'); -dispatch_get('/cape/strings', 'GetCapeStringOptions'); -dispatch_get('/cape/panel', 'GetCapePanelOptions'); -dispatch_get('/cape/strings/:key', 'GetCapeStringConfig'); -dispatch_get('/cape/panel/:key', 'GetCapePanelConfig'); - -dispatch_get('/channel/input/stats', 'channel_input_get_stats'); -dispatch_delete('/channel/input/stats', 'channel_input_delete_stats'); -dispatch_get('/channel/output/processors', 'channel_get_output_processors'); -dispatch_post('/channel/output/processors', 'channel_save_output_processors'); -dispatch_get('/channel/output/:file', 'channel_get_output'); -dispatch_post('/channel/output/:file', 'channel_save_output'); - -dispatch_get('/configfile', 'GetConfigFileList'); -dispatch_get('/configfile/**', 'DownloadConfigFile'); -dispatch_post('/configfile/**', 'UploadConfigFile'); -dispatch_delete('/configfile/**', 'DeleteConfigFile'); - -dispatch_post('/dir/:DirName/:SubDir', 'CreateDir'); -dispatch_delete('/dir/:DirName/:SubDir', 'DeleteDir'); - -dispatch_get('/effects', 'effects_list'); -dispatch_get('/effects/ALL', 'effects_list_ALL'); - -dispatch_post('/email/configure', 'ConfigureEmail'); -dispatch_post('/email/test', 'SendTestEmail'); - -dispatch_get('/events', 'events_list'); -dispatch_get('/events/:eventId', 'event_get'); -dispatch_get('/events/:eventId/trigger', 'event_trigger'); - -dispatch_get('/files/Sequences/fps', 'GetSequenceFPS'); // keep above files/:DirName -dispatch_get('/files/:DirName', 'GetFiles'); -dispatch_get('/file/info/:plugin/:ext/**', 'GetPluginFileInfo'); // keep above file/:DirName -dispatch_get('/file/onUpload/:ext/**', 'PluginFileOnUpload'); // keep above file/:DirName -dispatch_get('/file/move/:fileName', 'MoveFile'); // keep above file/:DirName -dispatch_get('/files/zip/:DirNames', 'GetZipDir'); -dispatch_post('/file/:DirName/copy/:source/:dest', 'files_copy'); -dispatch_post('/file/:DirName/rename/:source/:dest', 'files_rename'); -dispatch_get('/file/:DirName/tailfollow/**', 'TailFollowFile'); -dispatch_get('/file/:DirName/**', 'GetFile'); -dispatch_delete('/file/:DirName/**', 'DeleteFile'); -dispatch_post('/file/:DirName', 'PatchFile'); -dispatch_patch('/file/:DirName', 'PatchFile'); -dispatch_post('/file/:DirName/:Name', 'PostFile'); - -dispatch_get('/git/originLog', 'GetGitOriginLog'); -dispatch_get('/git/releases/os/:All', 'GitOSReleases'); -dispatch_get('/git/releases/notes/:tag', 'GitOSReleaseNotes'); -dispatch_get('/git/releases/sizes', 'GitOSReleaseSizes'); -dispatch_get('/git/reset', 'GitReset'); -dispatch_get('/git/status', 'GitStatus'); -dispatch_get('/git/branches', 'GitBranches'); - -dispatch_get('/media', 'GetMedia'); -dispatch_get('/media/:MediaName/duration', 'GetMediaDuration'); -dispatch_get('/media/:MediaName/meta', 'GetMediaMetaData'); - -dispatch_get('/network/dns', 'network_get_dns'); -dispatch_post('/network/dns', 'network_save_dns'); -dispatch_get('/network/gateway', 'network_get_gateway'); -dispatch_post('/network/gateway', 'network_save_gateway'); -dispatch_get('/network/interface', 'network_list_interfaces'); -dispatch_get('/network/interface/:interface', 'network_get_interface'); -dispatch_get('/network/interface/add/:interface', 'network_add_interface'); -dispatch_post('/network/interface/:interface', 'network_set_interface'); -dispatch_post('/network/interface/:interface/apply', 'network_apply_interface'); - -dispatch_delete('/network/presisentNames', 'network_persistentNames_delete'); -dispatch_post('/network/presisentNames', 'network_persistentNames_create'); -dispatch_delete('/network/persistentNames', 'network_persistentNames_delete'); -dispatch_post('/network/persistentNames', 'network_persistentNames_create'); -dispatch_get('/network/wifi/scan/:interface', 'network_wifi_scan'); -dispatch_get('/network/wifi/status/:interface', 'network_wifi_status'); -dispatch_get('/network/wifi/strength', 'network_wifi_strength'); - -dispatch_get('/options/:SettingName', 'GetOptions'); - -dispatch_get('/audio/cardaliases', 'GetAudioCardAliases'); -dispatch_post('/audio/cardaliases', 'SaveAudioCardAliases'); - -dispatch_get('/pipewire/audio/groups', 'GetPipeWireAudioGroups'); -dispatch_post('/pipewire/audio/groups', 'SavePipeWireAudioGroups'); -dispatch_post('/pipewire/audio/groups/apply', 'ApplyPipeWireAudioGroups'); -dispatch_get('/pipewire/audio/sinks', 'GetPipeWireSinks'); -dispatch_get('/pipewire/audio/cards', 'GetPipeWireAudioCards'); -dispatch_get('/pipewire/audio/usb-check', 'GetUsbAudioBandwidthCheck'); -dispatch_get('/pipewire/audio/sources', 'GetPipeWireAudioSources'); -dispatch_get('/pipewire/audio/plugin-sources', 'GetPipeWirePluginSources'); -dispatch_get('/pipewire/audio/input-groups', 'GetPipeWireInputGroups'); -dispatch_post('/pipewire/audio/input-groups', 'SavePipeWireInputGroups'); -dispatch_post('/pipewire/audio/input-groups/apply', 'ApplyPipeWireInputGroups'); -dispatch_post('/pipewire/audio/input-groups/volume', 'SetInputGroupMemberVolume'); -dispatch_post('/pipewire/audio/input-groups/effects', 'SaveInputGroupEffects'); -dispatch_post('/pipewire/audio/input-groups/eq/update', 'UpdateInputGroupEQRealtime'); -dispatch_get('/pipewire/audio/routing', 'GetRoutingMatrix'); -dispatch_post('/pipewire/audio/routing', 'SaveRoutingMatrix'); -dispatch_post('/pipewire/audio/routing/volume', 'SetRoutingPathVolume'); -dispatch_get('/pipewire/audio/routing/presets', 'GetRoutingPresets'); -dispatch_get('/pipewire/audio/routing/presets/names', 'GetRoutingPresetNames'); -dispatch_post('/pipewire/audio/routing/presets', 'SaveRoutingPreset'); -dispatch_post('/pipewire/audio/routing/presets/load', 'LoadRoutingPreset'); -dispatch_post('/pipewire/audio/routing/presets/live-apply', 'LiveApplyRoutingPreset'); -dispatch_delete('/pipewire/audio/routing/presets/:name', 'DeleteRoutingPreset'); -dispatch_post('/pipewire/audio/stream/volume', 'SetStreamSlotVolume'); -dispatch_get('/pipewire/audio/stream/status', 'GetStreamSlotStatus'); -dispatch_post('/pipewire/audio/group/volume', 'SetPipeWireGroupVolume'); -dispatch_post('/pipewire/audio/eq/update', 'UpdatePipeWireEQRealtime'); -dispatch_post('/pipewire/audio/delay/update', 'UpdatePipeWireDelayRealtime'); -dispatch_post('/pipewire/audio/sync/start', 'StartSyncCalibration'); -dispatch_post('/pipewire/audio/sync/stop', 'StopSyncCalibration'); -dispatch_post('/pipewire/audio/services/restart', 'RestartPipeWireServices'); -dispatch_get('/pipewire/video/groups', 'GetPipeWireVideoGroups'); -dispatch_post('/pipewire/video/groups', 'SavePipeWireVideoGroups'); -dispatch_post('/pipewire/video/groups/apply', 'ApplyPipeWireVideoGroups'); -dispatch_post('/pipewire/simple/apply', 'ApplyPipeWireSimpleConfig'); -dispatch_get('/pipewire/video/connectors', 'GetVideoOutputTargets'); -dispatch_get('/pipewire/video/routing', 'GetVideoRoutingMatrix'); -dispatch_post('/pipewire/video/routing', 'SaveVideoRoutingMatrix'); -dispatch_get('/pipewire/video/input-sources', 'GetPipeWireVideoInputSources'); -dispatch_post('/pipewire/video/input-sources', 'SavePipeWireVideoInputSources'); -dispatch_post('/pipewire/video/input-sources/apply', 'ApplyPipeWireVideoInputSources'); -dispatch_get('/pipewire/video/input-sources/v4l2-devices', 'GetV4L2Devices'); - -dispatch_get('/pipewire/aes67/instances', 'GetAES67Instances'); -dispatch_post('/pipewire/aes67/instances', 'SaveAES67Instances'); -dispatch_post('/pipewire/aes67/apply', 'ApplyAES67Instances'); -dispatch_get('/pipewire/aes67/status', 'GetAES67Status'); -dispatch_get('/pipewire/aes67/interfaces', 'GetAES67NetworkInterfaces'); - -dispatch_get('/pipewire/opusrtp/instances', 'GetOpusRTPInstances'); -dispatch_post('/pipewire/opusrtp/instances', 'SaveOpusRTPInstances'); -dispatch_post('/pipewire/opusrtp/apply', 'ApplyOpusRTPInstances'); -dispatch_get('/pipewire/opusrtp/status', 'GetOpusRTPStatus'); -dispatch_get('/pipewire/opusrtp/interfaces', 'GetOpusRTPNetworkInterfaces'); -dispatch_get('/pipewire/graph', 'GetPipeWireGraph'); +$apiVersionPrefixes = ['', '/v2']; + +function dispatch_all(string $path, string $method, string $fn): void { + global $apiVersionPrefixes; + foreach ($apiVersionPrefixes as $prefix) { + call_user_func("dispatch_{$method}", $prefix . $path, $fn); + } +} + +// Spec / docs — v1 reads from www/api/v1/, v2 reads from www/api/v2/ +dispatch_get('/', 'ServeApiDocs_v1'); +dispatch_get('/api.html', 'ServeApiHtml_v1'); +dispatch_get('/openapi.yaml', 'ServeOpenApiSpec_v1'); +dispatch_get('/openapi.json', 'ServeOpenApiSpec_v1'); +dispatch_get('/v1/', 'ServeApiDocs_v1'); +dispatch_get('/v1/api.html', 'ServeApiHtml_v1'); +dispatch_get('/v1/openapi.yaml', 'ServeOpenApiSpec_v1'); +dispatch_get('/v1/openapi.json', 'ServeOpenApiSpec_v1'); +dispatch_get('/v2/', 'ServeApiDocs_v2'); +dispatch_get('/v2/api.html', 'ServeApiHtml_v2'); +dispatch_get('/v2/openapi.yaml', 'ServeOpenApiSpec_v2'); +dispatch_get('/v2/openapi.json', 'ServeOpenApiSpec_v2'); + +// Backups +dispatch_all('/backups/list', 'get', 'GetAvailableBackups'); +dispatch_all('/backups/list/:DeviceName', 'get', 'GetAvailableBackupsOnDevice'); +dispatch_all('/backups/devices', 'get', 'RetrieveAvailableBackupsDevices'); +dispatch_all('/backups/devices/mount/:DeviceName/:MountLocation', 'post', 'MountDevice'); +dispatch_all('/backups/devices/unmount/:DeviceName/:MountLocation', 'post', 'UnmountDevice'); +dispatch_all('/backups/configuration', 'post', 'MakeJSONBackup'); +dispatch_all('/backups/configuration/list', 'get', 'GetAvailableJSONBackups'); +dispatch_all('/backups/configuration/list/:DeviceName', 'get', 'GetAvailableJSONBackupsOnDevice'); +dispatch_all('/backups/configuration/restore/:Directory/:BackupFilename', 'post', 'RestoreJsonBackup'); +dispatch_all('/backups/configuration/:Directory/:BackupFilename', 'get', 'DownloadJsonBackup'); +dispatch_all('/backups/configuration/:Directory/:BackupFilename', 'delete', 'DeleteJsonBackup'); + +// Cape +dispatch_all('/cape', 'get', 'GetCapeInfo'); +dispatch_all('/cape/eeprom/voucher', 'post', 'RedeemVoucher'); +dispatch_all('/cape/eeprom/sign/:key/:order', 'post', 'SignEEPROM'); +dispatch_all('/cape/eeprom/signingData/:key/:order', 'get', 'GetSigningData'); +dispatch_all('/cape/eeprom/signingFile/:key/:order', 'get', 'GetSigningFile'); +dispatch_all('/cape/eeprom/signingData', 'post', 'PostSigningData'); +dispatch_all('/cape/options', 'get', 'GetCapeOptions'); +dispatch_all('/cape/strings', 'get', 'GetCapeStringOptions'); +dispatch_all('/cape/panel', 'get', 'GetCapePanelOptions'); +dispatch_all('/cape/strings/:key', 'get', 'GetCapeStringConfig'); +dispatch_all('/cape/panel/:key', 'get', 'GetCapePanelConfig'); + +// Channel +dispatch_all('/channel/input/stats', 'get', 'ChannelInputGetStats'); +dispatch_all('/channel/input/stats', 'delete', 'ChannelInputDeleteStats'); +dispatch_all('/channel/output/processors', 'get', 'ChannelGetOutputProcessors'); +dispatch_all('/channel/output/processors', 'post', 'ChannelSaveOutputProcessors'); +dispatch_all('/channel/output/:file', 'get', 'ChannelGetOutput'); +dispatch_all('/channel/output/:file', 'post', 'ChannelSaveOutput'); + +// Config files +dispatch_all('/configfile', 'get', 'GetConfigFileList'); +dispatch_all('/configfile/**', 'get', 'DownloadConfigFile'); +dispatch_all('/configfile/**', 'post', 'UploadConfigFile'); +dispatch_all('/configfile/**', 'delete', 'DeleteConfigFile'); + +// Directories +dispatch_all('/dir/:DirName/:SubDir', 'post', 'CreateDir'); +dispatch_all('/dir/:DirName/:SubDir', 'delete', 'DeleteDir'); + +// Effects +dispatch_all('/effects', 'get', 'EffectsList'); +dispatch_all('/effects/ALL', 'get', 'EffectsListAll'); + +// Email +dispatch_all('/email/configure', 'post', 'ConfigureEmail'); +dispatch_all('/email/test', 'post', 'SendTestEmail'); + +// Events +dispatch_all('/events', 'get', 'EventsList'); +dispatch_all('/events/:eventId', 'get', 'EventGet'); +dispatch_get('/events/:eventId/trigger', 'EventTrigger'); // v1: GET +dispatch_post('/v2/events/:eventId/trigger', 'EventTrigger'); // v2: POST + +// Files — more-specific routes must precede /file/:DirName/** +dispatch_all('/files/Sequences/fps', 'get', 'GetSequenceFPS'); // keep above /files/:DirName +dispatch_all('/files/:DirName', 'get', 'GetFiles'); +dispatch_all('/file/info/:plugin/:ext/**', 'get', 'GetPluginFileInfo'); // keep above /file/:DirName +dispatch_get('/file/onUpload/:ext/**', 'PluginFileOnUpload'); // v1: GET; keep above /file/:DirName +dispatch_post('/v2/file/onUpload/:ext/**', 'PluginFileOnUpload'); // v2: POST +dispatch_get('/file/move/:fileName', 'MoveFile'); // v1: GET; keep above /file/:DirName +dispatch_post('/v2/file/move/:fileName', 'MoveFile'); // v2: POST +dispatch_all('/files/zip/:DirNames', 'get', 'GetZipDir'); +dispatch_all('/file/:DirName/copy/:source/:dest', 'post', 'FilesCopy'); +dispatch_all('/file/:DirName/rename/:source/:dest', 'post', 'FilesRename'); +dispatch_all('/file/:DirName/tailfollow/**', 'get', 'TailFollowFile'); +dispatch_all('/file/:DirName/**', 'get', 'GetFile'); +dispatch_all('/file/:DirName/**', 'delete', 'DeleteFile'); +dispatch_all('/file/:DirName', 'post', 'PatchFile'); +dispatch_all('/file/:DirName', 'patch', 'PatchFile'); +dispatch_all('/file/:DirName/:Name', 'post', 'PostFile'); + +// Git +dispatch_all('/git/originLog', 'get', 'GetGitOriginLog'); +dispatch_all('/git/releases/os/:All', 'get', 'GitOSReleases'); +dispatch_all('/git/releases/notes/:tag', 'get', 'GitOSReleaseNotes'); +dispatch_all('/git/releases/sizes', 'get', 'GitOSReleaseSizes'); +dispatch_get('/git/reset', 'GitReset'); // v1: GET +dispatch_post('/v2/git/reset', 'GitReset'); // v2: POST +dispatch_all('/git/status', 'get', 'GitStatus'); +dispatch_all('/git/branches', 'get', 'GitBranches'); + +// Media +dispatch_all('/media', 'get', 'GetMedia'); +dispatch_all('/media/:MediaName/duration', 'get', 'GetMediaDuration'); +dispatch_all('/media/:MediaName/meta', 'get', 'GetMediaMetaData'); + +// Network +dispatch_all('/network/dns', 'get', 'NetworkGetDNS'); +dispatch_all('/network/dns', 'post', 'NetworkSaveDNS'); +dispatch_all('/network/gateway', 'get', 'NetworkGetGateway'); +dispatch_all('/network/gateway', 'post', 'NetworkSaveGateway'); +dispatch_all('/network/interface', 'get', 'NetworkListInterfaces'); +dispatch_all('/network/interface/:interface', 'get', 'NetworkGetInterface'); +dispatch_get('/network/interface/add/:interface', 'NetworkAddInterface'); // v1: GET +dispatch_post('/v2/network/interface/add/:interface', 'NetworkAddInterface'); // v2: POST +dispatch_all('/network/interface/:interface', 'post', 'NetworkSetInterface'); +dispatch_all('/network/interface/:interface/apply', 'post', 'NetworkApplyInterface'); +dispatch_all('/network/presisentNames', 'delete', 'NetworkPersistentNamesDelete'); +dispatch_all('/network/presisentNames', 'post', 'NetworkPersistentNamesCreate'); +dispatch_all('/network/persistentNames', 'delete', 'NetworkPersistentNamesDelete'); +dispatch_all('/network/persistentNames', 'post', 'NetworkPersistentNamesCreate'); +dispatch_all('/network/wifi/scan/:interface', 'get', 'NetworkWiFiScan'); +dispatch_all('/network/wifi/status/:interface', 'get', 'NetworkWiFiStatus'); +dispatch_all('/network/wifi/strength', 'get', 'NetworkWiFiStrength'); + +// Options +dispatch_all('/options/:SettingName', 'get', 'GetOptions'); + +// Audio +dispatch_all('/audio/cardaliases', 'get', 'GetAudioCardAliases'); +dispatch_all('/audio/cardaliases', 'post', 'SaveAudioCardAliases'); + +// PipeWire +dispatch_all('/pipewire/audio/groups', 'get', 'GetPipeWireAudioGroups'); +dispatch_all('/pipewire/audio/groups', 'post', 'SavePipeWireAudioGroups'); +dispatch_all('/pipewire/audio/groups/apply', 'post', 'ApplyPipeWireAudioGroups'); +dispatch_all('/pipewire/audio/sinks', 'get', 'GetPipeWireSinks'); +dispatch_all('/pipewire/audio/cards', 'get', 'GetPipeWireAudioCards'); +dispatch_all('/pipewire/audio/usb-check', 'get', 'GetUsbAudioBandwidthCheck'); +dispatch_all('/pipewire/audio/sources', 'get', 'GetPipeWireAudioSources'); +dispatch_all('/pipewire/audio/plugin-sources', 'get', 'GetPipeWirePluginSources'); +dispatch_all('/pipewire/audio/input-groups', 'get', 'GetPipeWireInputGroups'); +dispatch_all('/pipewire/audio/input-groups', 'post', 'SavePipeWireInputGroups'); +dispatch_all('/pipewire/audio/input-groups/apply', 'post', 'ApplyPipeWireInputGroups'); +dispatch_all('/pipewire/audio/input-groups/volume', 'post', 'SetInputGroupMemberVolume'); +dispatch_all('/pipewire/audio/input-groups/effects', 'post', 'SaveInputGroupEffects'); +dispatch_all('/pipewire/audio/input-groups/eq/update', 'post', 'UpdateInputGroupEQRealtime'); +dispatch_all('/pipewire/audio/routing', 'get', 'GetRoutingMatrix'); +dispatch_all('/pipewire/audio/routing', 'post', 'SaveRoutingMatrix'); +dispatch_all('/pipewire/audio/routing/volume', 'post', 'SetRoutingPathVolume'); +dispatch_all('/pipewire/audio/routing/presets', 'get', 'GetRoutingPresets'); +dispatch_all('/pipewire/audio/routing/presets/names', 'get', 'GetRoutingPresetNames'); +dispatch_all('/pipewire/audio/routing/presets', 'post', 'SaveRoutingPreset'); +dispatch_all('/pipewire/audio/routing/presets/load', 'post', 'LoadRoutingPreset'); +dispatch_all('/pipewire/audio/routing/presets/live-apply', 'post', 'LiveApplyRoutingPreset'); +dispatch_all('/pipewire/audio/routing/presets/:name', 'delete', 'DeleteRoutingPreset'); +dispatch_all('/pipewire/audio/stream/volume', 'post', 'SetStreamSlotVolume'); +dispatch_all('/pipewire/audio/stream/status', 'get', 'GetStreamSlotStatus'); +dispatch_all('/pipewire/audio/group/volume', 'post', 'SetPipeWireGroupVolume'); +dispatch_all('/pipewire/audio/eq/update', 'post', 'UpdatePipeWireEQRealtime'); +dispatch_all('/pipewire/audio/delay/update', 'post', 'UpdatePipeWireDelayRealtime'); +dispatch_all('/pipewire/audio/sync/start', 'post', 'StartSyncCalibration'); +dispatch_all('/pipewire/audio/sync/stop', 'post', 'StopSyncCalibration'); +dispatch_all('/pipewire/audio/services/restart', 'post', 'RestartPipeWireServices'); +dispatch_all('/pipewire/video/groups', 'get', 'GetPipeWireVideoGroups'); +dispatch_all('/pipewire/video/groups', 'post', 'SavePipeWireVideoGroups'); +dispatch_all('/pipewire/video/groups/apply', 'post', 'ApplyPipeWireVideoGroups'); +dispatch_all('/pipewire/simple/apply', 'post', 'ApplyPipeWireSimpleConfig'); +dispatch_all('/pipewire/video/connectors', 'get', 'GetVideoOutputTargets'); +dispatch_all('/pipewire/video/routing', 'get', 'GetVideoRoutingMatrix'); +dispatch_all('/pipewire/video/routing', 'post', 'SaveVideoRoutingMatrix'); +dispatch_all('/pipewire/video/input-sources', 'get', 'GetPipeWireVideoInputSources'); +dispatch_all('/pipewire/video/input-sources', 'post', 'SavePipeWireVideoInputSources'); +dispatch_all('/pipewire/video/input-sources/apply', 'post', 'ApplyPipeWireVideoInputSources'); +dispatch_all('/pipewire/video/input-sources/v4l2-devices', 'get', 'GetV4L2Devices'); +dispatch_all('/pipewire/aes67/instances', 'get', 'GetAES67Instances'); +dispatch_all('/pipewire/aes67/instances', 'post', 'SaveAES67Instances'); +dispatch_all('/pipewire/aes67/apply', 'post', 'ApplyAES67Instances'); +dispatch_all('/pipewire/aes67/status', 'get', 'GetAES67Status'); +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'); +dispatch_all('/playlists/playable', 'get', 'PlaylistPlayable'); +dispatch_all('/playlists/validate', 'get', 'PlaylistListValidate'); +dispatch_get('/playlists/stop', 'PlaylistStop'); // v1: GET +dispatch_post('/v2/playlists/stop', 'PlaylistStop'); // v2: POST +dispatch_get('/playlists/pause', 'PlaylistPause'); // v1: GET +dispatch_post('/v2/playlists/pause', 'PlaylistPause'); // v2: POST +dispatch_get('/playlists/resume', 'PlaylistResume'); // v1: GET +dispatch_post('/v2/playlists/resume', 'PlaylistResume'); // v2: POST +dispatch_get('/playlists/stopgracefully', 'PlaylistStopGracefully'); // v1: GET +dispatch_post('/v2/playlists/stopgracefully', 'PlaylistStopGracefully'); // v2: POST +dispatch_get('/playlists/stopgracefullyafterloop', 'PlaylistStopGracefullyAfterLoop'); // v1: GET +dispatch_post('/v2/playlists/stopgracefullyafterloop', 'PlaylistStopGracefullyAfterLoop'); // v2: POST +dispatch_all('/playlist/:PlaylistName', 'get', 'PlaylistGet'); +dispatch_get('/playlist/:PlaylistName/start', 'PlaylistStart'); // v1: GET +dispatch_post('/v2/playlist/:PlaylistName/start', 'PlaylistStart'); // v2: POST +dispatch_get('/playlist/:PlaylistName/start/:Repeat', 'PlaylistStartRepeat'); // v1: GET +dispatch_post('/v2/playlist/:PlaylistName/start/:Repeat', 'PlaylistStartRepeat'); // v2: POST +dispatch_get('/playlist/:PlaylistName/start/:Repeat/:ScheduleProtected', 'PlaylistStartRepeatProtected'); // v1: GET +dispatch_post('/v2/playlist/:PlaylistName/start/:Repeat/:ScheduleProtected', 'PlaylistStartRepeatProtected'); // v2: POST +dispatch_all('/playlist/:PlaylistName', 'post', 'PlaylistUpdate'); +dispatch_all('/playlist/:PlaylistName', 'delete', 'PlaylistDelete'); +dispatch_all('/playlist/:PlaylistName/:SectionName/item', 'post', 'PlaylistSectionInsertItem'); // PipeWire control facade — clean, ID-addressed, live-state 3rd-party API -dispatch_get('/pipewire/control/status', 'PWCtl_GetStatus'); -dispatch_get('/pipewire/control/groups', 'PWCtl_GetGroups'); -dispatch_get('/pipewire/control/groups/:id', 'PWCtl_GetGroup'); -dispatch_post('/pipewire/control/groups/:id/volume', 'PWCtl_SetGroupVolume'); -dispatch_post('/pipewire/control/groups/:id/mute', 'PWCtl_SetGroupMute'); -dispatch_post('/pipewire/control/groups/:id/members/:cardId/volume', 'PWCtl_SetMemberVolume'); -dispatch_post('/pipewire/control/groups/:id/members/:cardId/mute', 'PWCtl_SetMemberMute'); -dispatch_get('/pipewire/control/input-groups', 'PWCtl_GetInputGroups'); -dispatch_get('/pipewire/control/input-groups/:id', 'PWCtl_GetInputGroup'); -dispatch_post('/pipewire/control/input-groups/:id/members/:memberIndex/volume', 'PWCtl_SetInputMemberVolume'); -dispatch_post('/pipewire/control/input-groups/:id/members/:memberIndex/mute', 'PWCtl_SetInputMemberMute'); -dispatch_get('/pipewire/control/streams', 'PWCtl_GetStreams'); -dispatch_post('/pipewire/control/streams/:slot/volume', 'PWCtl_SetStreamVolume'); -dispatch_get('/pipewire/control/routing', 'PWCtl_GetRouting'); -dispatch_post('/pipewire/control/routing/:inputGroupId/:outputGroupId/volume', 'PWCtl_SetRoutingVolume'); -dispatch_post('/pipewire/control/routing/:inputGroupId/:outputGroupId/mute', 'PWCtl_SetRoutingMute'); - -dispatch_get('/playlists', 'playlist_list'); -dispatch_post('/playlists', 'playlist_insert'); -dispatch_get('/playlists/playable', 'playlist_playable'); -dispatch_get('/playlists/validate', 'playlist_list_validate'); -dispatch_get('/playlists/stop', 'playlist_stop'); -dispatch_get('/playlists/pause', 'playlist_pause'); -dispatch_get('/playlists/resume', 'playlist_resume'); -dispatch_get('/playlists/stopgracefully', 'playlist_stopgracefully'); -dispatch_get('/playlists/stopgracefullyafterloop', 'playlist_stopgracefullyafterloop'); -dispatch_get('/playlist/:PlaylistName', 'playlist_get'); -dispatch_get('/playlist/:PlaylistName/start', 'playlist_start'); -dispatch_get('/playlist/:PlaylistName/start/:Repeat', 'playlist_start_repeat'); -dispatch_get('/playlist/:PlaylistName/start/:Repeat/:ScheduleProtected', 'playlist_start_repeat_protected'); -dispatch_post('/playlist/:PlaylistName', 'playlist_update'); -dispatch_delete('/playlist/:PlaylistName', 'playlist_delete'); -dispatch_post('/playlist/:PlaylistName/:SectionName/item', 'PlaylistSectionInsertItem'); - -dispatch_get('/plugin/headerIndicators', 'GetPluginHeaderIndicators'); -dispatch_get('/plugin', 'GetInstalledPlugins'); -dispatch_post('/plugin', 'InstallPlugin'); -dispatch_post('/plugin/fetchInfo', 'FetchPluginInfoProxy'); -dispatch_get('/plugin/popularity', 'GetPluginPopularity'); // keep above /plugin/:RepoName -dispatch_get('/plugin/githubStats', 'GetPluginGitHubStats'); // keep above /plugin/:RepoName -dispatch_get('/plugin/fetchImage', 'PluginFetchImage'); // keep above /plugin/:RepoName -dispatch_get('/plugin/:RepoName', 'GetPluginInfo'); -dispatch_get('/plugin/:RepoName/icon', 'PluginServeIcon'); -dispatch_get('/plugin/:RepoName/page', 'GetPluginPageUrl'); -dispatch_delete('/plugin/:RepoName', 'UninstallPlugin'); -dispatch_get('/plugin/:RepoName/settings/:SettingName', 'PluginGetSetting'); -dispatch_put('/plugin/:RepoName/settings/:SettingName', 'PluginSetSetting'); -dispatch_post('/plugin/:RepoName/settings/:SettingName', 'PluginSetSetting'); -dispatch_post('/plugin/:RepoName/updates', 'CheckForPluginUpdates'); -dispatch_get('/plugin/:RepoName/upgrade', 'UpgradePlugin'); -dispatch_post('/plugin/:RepoName/upgrade', 'UpgradePlugin'); +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'); + +// Plugins +dispatch_all('/plugin/headerIndicators', 'get', 'GetPluginHeaderIndicators'); +dispatch_all('/plugin', 'get', 'GetInstalledPlugins'); +dispatch_all('/plugin', 'post', 'InstallPlugin'); +dispatch_all('/plugin/fetchInfo', 'post', 'FetchPluginInfoProxy'); +dispatch_all('/plugin/popularity', 'get', 'GetPluginPopularity'); // keep above /plugin/:RepoName +dispatch_all('/plugin/githubStats', 'get', 'GetPluginGitHubStats'); // keep above /plugin/:RepoName +dispatch_all('/plugin/fetchImage', 'get', 'PluginFetchImage'); // keep above /plugin/:RepoName +dispatch_all('/plugin/:RepoName', 'get', 'GetPluginInfo'); +dispatch_all('/plugin/:RepoName/icon', 'get', 'PluginServeIcon'); +dispatch_all('/plugin/:RepoName/page', 'get', 'GetPluginPageUrl'); +dispatch_all('/plugin/:RepoName', 'delete', 'UninstallPlugin'); +dispatch_all('/plugin/:RepoName/settings/:SettingName', 'get', 'PluginGetSetting'); +dispatch_all('/plugin/:RepoName/settings/:SettingName', 'put', 'PluginSetSetting'); +dispatch_all('/plugin/:RepoName/settings/:SettingName', 'post', 'PluginSetSetting'); +dispatch_all('/plugin/:RepoName/updates', 'post', 'CheckForPluginUpdates'); +dispatch_get('/plugin/:RepoName/upgrade', 'UpgradePlugin'); // v1: GET (backward compat only) +dispatch_all('/plugin/:RepoName/upgrade', 'post', 'UpgradePlugin'); // NOTE: Plugins may also implement their own /plugin/:RepoName/* endpoints // which are added after the above endpoints via addPluginEndpoints() below. -dispatch_get('/proxies', 'GetProxies'); -dispatch_post('/proxies', 'PostProxies'); -dispatch_delete('/proxies', 'DeleteAllProxies'); -dispatch_post('/proxies/:ProxyIp', 'AddProxy'); -dispatch_delete('/proxies/:ProxyIp', 'DeleteProxy'); - -dispatch_get(array('/proxy/*/**', array("Ip", "urlPart")), 'GetProxiedURL'); - -dispatch_get('/remotes', 'GetRemotes'); -dispatch_get('/remoteAction', 'remoteAction'); - -dispatch_get('/geoip', 'GetGeoIP'); - -dispatch_get('/sequence', 'GetSequences'); -dispatch_get('/sequence/current/step', 'GetSequenceStep'); -dispatch_get('/sequence/current/stop', 'GetSequenceStop'); -dispatch_get('/sequence/current/togglePause', 'GetSequenceTogglePause'); -dispatch_get('/sequence/:SequenceName', 'GetSequence'); -dispatch_get('/sequence/:SequenceName/meta', 'GetSequenceMetaData'); -dispatch_get('/sequence/:SequenceName/start/:startSecond', 'GetSequenceStart'); -dispatch_post('/sequence/:SequenceName', 'PostSequence'); -dispatch_delete('/sequence/:SequenceName', 'DeleteSequence'); - -dispatch_post('/schedule/reload', 'ReloadSchedule'); -dispatch_get('/schedule', 'GetSchedule'); -dispatch_post('/schedule', 'SaveSchedule'); - -dispatch_get('/settings', 'GetSettings'); -dispatch_post('/settings/fanThermal/reset', 'ResetFanThermalTrips'); -dispatch_get('/settings/:SettingName', 'GetSetting'); -dispatch_get('/settings/:SettingName/options', 'GetOptions'); -dispatch_put('/settings/:SettingName', 'PutSetting'); -dispatch_put('/settings/:SettingName/jsonValueUpdate', 'UpdateJSONValueSetting'); - -dispatch_get('/scripts', 'scripts_list'); -dispatch_get('/scripts/installRemote/:category/:filename', 'scripts_install_remote'); -dispatch_get('/scripts/viewRemote/:category/:filename', 'scripts_view_remote'); -dispatch_get('/scripts/:scriptName', 'script_get'); -dispatch_post('/scripts/:scriptName', 'script_save'); -dispatch_get('/scripts/:scriptName/run', 'script_run'); - -dispatch_get('/statistics/usage', 'stats_get_last_file'); -dispatch_post('/statistics/usage', 'stats_publish_stats_file'); -dispatch_delete('/statistics/usage', 'stats_delete_last_file'); - -dispatch_get('/system/fppd/restart', 'RestartFPPD'); -dispatch_get('/system/fppd/start', 'StartFPPD'); -dispatch_get('/system/fppd/stop', 'StopFPPD'); -dispatch_post('/system/fppd/skipBootDelay', 'SkipBootDelay'); -dispatch_get('/system/reboot', 'RebootDevice'); -dispatch_get('/system/releaseNotes/:version', 'ViewReleaseNotes'); -dispatch_get('/system/shutdown', 'SystemShutdownOS'); -dispatch_get('/system/status', 'SystemGetStatus'); -dispatch_get('/system/updateStatus', 'GetUpdateStatus'); -dispatch_get('/system/info', 'SystemGetInfo'); -dispatch_get('/system/volume', 'SystemGetAudio'); -dispatch_post('/system/volume', 'SystemSetAudio'); -dispatch_post('/system/proxies', 'PostProxies'); -dispatch_get('/system/proxies', 'GetProxies'); -dispatch_get('/system/packages', 'GetOSpackages'); -dispatch_get('/system/packages/info/:packageName', 'GetOSpackageInfo'); - -dispatch_get('/testmode', 'testMode_Get'); -dispatch_post('/testmode', 'testMode_Set'); - -dispatch_get('/time', 'GetTime'); +// Proxies +dispatch_all('/proxies', 'get', 'GetProxies'); +dispatch_all('/proxies', 'post', 'PostProxies'); +dispatch_all('/proxies', 'delete', 'DeleteAllProxies'); +dispatch_all('/proxies/:ProxyIp', 'post', 'AddProxy'); +dispatch_all('/proxies/:ProxyIp', 'delete', 'DeleteProxy'); + +foreach (['', '/v2'] as $prefix) { + dispatch_get(array($prefix . '/proxy/*/**', array("Ip", "urlPart")), 'GetProxiedURL'); +} + +// Remotes +dispatch_all('/remotes', 'get', 'GetRemotes'); +dispatch_get('/remoteAction', 'RemoteAction_v1'); // v1: GET + query params +dispatch_post('/v2/remoteAction', 'RemoteAction'); // v2: POST + JSON body + +// GeoIP +dispatch_all('/geoip', 'get', 'GetGeoIP'); + +// Sequences +dispatch_all('/sequence', 'get', 'GetSequences'); +dispatch_get('/sequence/current/step', 'GetSequenceStep'); // v1: GET +dispatch_post('/v2/sequence/current/step', 'GetSequenceStep'); // v2: POST +dispatch_get('/sequence/current/stop', 'GetSequenceStop'); // v1: GET +dispatch_post('/v2/sequence/current/stop', 'GetSequenceStop'); // v2: POST +dispatch_get('/sequence/current/togglePause', 'GetSequenceTogglePause'); // v1: GET +dispatch_post('/v2/sequence/current/togglePause', 'GetSequenceTogglePause'); // v2: POST +dispatch_all('/sequence/:SequenceName', 'get', 'GetSequence'); +dispatch_all('/sequence/:SequenceName/meta', 'get', 'GetSequenceMetaData'); +dispatch_get('/sequence/:SequenceName/start/:startSecond', 'GetSequenceStart'); // v1: GET +dispatch_post('/v2/sequence/:SequenceName/start/:startSecond', 'GetSequenceStart'); // v2: POST +dispatch_all('/sequence/:SequenceName', 'post', 'PostSequence'); +dispatch_all('/sequence/:SequenceName', 'delete', 'DeleteSequence'); + +// Schedule +dispatch_all('/schedule/reload', 'post', 'ReloadSchedule'); +dispatch_all('/schedule', 'get', 'GetSchedule'); +dispatch_all('/schedule', 'post', 'SaveSchedule'); + +// Settings +dispatch_all('/settings', 'get', 'GetSettings'); +dispatch_all('/settings/fanThermal/reset', 'post', 'ResetFanThermalTrips'); +dispatch_all('/settings/:SettingName', 'get', 'GetSetting'); +dispatch_all('/settings/:SettingName/options', 'get', 'GetOptions'); +dispatch_all('/settings/:SettingName', 'put', 'PutSetting'); +dispatch_all('/settings/:SettingName/jsonValueUpdate', 'put', 'UpdateJSONValueSetting'); + +// Scripts +dispatch_all('/scripts', 'get', 'ScriptsList'); +dispatch_get('/scripts/installRemote/:category/:filename', 'ScriptsInstallRemote'); // v1: GET +dispatch_post('/v2/scripts/installRemote/:category/:filename', 'ScriptsInstallRemote'); // v2: POST +dispatch_all('/scripts/viewRemote/:category/:filename', 'get', 'ScriptsViewRemote'); +dispatch_all('/scripts/:scriptName', 'get', 'ScriptGet'); +dispatch_all('/scripts/:scriptName', 'post', 'ScriptSave'); +dispatch_get('/scripts/:scriptName/run', 'ScriptRun'); // v1: GET +dispatch_post('/v2/scripts/:scriptName/run', 'ScriptRun'); // v2: POST + +// Statistics +dispatch_all('/statistics/usage', 'get', 'StatsGetLastFile'); +dispatch_all('/statistics/usage', 'post', 'StatsPublishStatsFile'); +dispatch_all('/statistics/usage', 'delete', 'StatsDeleteLastFile'); + +// System +dispatch_get('/system/fppd/restart', 'RestartFPPD'); // v1: GET +dispatch_post('/v2/system/fppd/restart', 'RestartFPPD'); // v2: POST +dispatch_get('/system/fppd/start', 'StartFPPD'); // v1: GET +dispatch_post('/v2/system/fppd/start', 'StartFPPD'); // v2: POST +dispatch_get('/system/fppd/stop', 'StopFPPD'); // v1: GET +dispatch_post('/v2/system/fppd/stop', 'StopFPPD'); // v2: POST +dispatch_all('/system/fppd/skipBootDelay', 'post', 'SkipBootDelay'); +dispatch_get('/system/reboot', 'RebootDevice'); // v1: GET +dispatch_post('/v2/system/reboot', 'RebootDevice'); // v2: POST +dispatch_all('/system/releaseNotes/:version', 'get', 'ViewReleaseNotes'); +dispatch_get('/system/shutdown', 'SystemShutdownOS'); // v1: GET +dispatch_post('/v2/system/shutdown', 'SystemShutdownOS'); // v2: POST +dispatch_all('/system/status', 'get', 'SystemGetStatus'); +dispatch_all('/system/updateStatus', 'get', 'GetUpdateStatus'); +dispatch_all('/system/info', 'get', 'SystemGetInfo'); +dispatch_all('/system/volume', 'get', 'SystemGetAudio'); +dispatch_all('/system/volume', 'post', 'SystemSetAudio'); +dispatch_all('/system/proxies', 'post', 'PostProxies'); +dispatch_all('/system/proxies', 'get', 'GetProxies'); +dispatch_all('/system/packages', 'get', 'GetOSPackages'); +dispatch_all('/system/packages/info/:packageName', 'get', 'GetOSPackageInfo'); + +// Test mode +dispatch_all('/testmode', 'get', 'TestModeGet'); +dispatch_all('/testmode', 'post', 'TestModeSet'); + +// Time +dispatch_all('/time', 'get', 'GetTime'); addPluginEndpoints(); @@ -354,37 +438,71 @@ function addPluginEndpoints() continue; } $path = '/plugin/' . $ep['plugin'] . '/' . $ep['endpoint']; - if ($ep['method'] == 'GET') { - dispatch_get($path, $ep['callback']); - } else if ($ep['method'] == 'POST') { - dispatch_post($path, $ep['callback']); - } else if ($ep['method'] == 'PUT') { - dispatch_put($path, $ep['callback']); - } else if ($ep['method'] == 'DELETE') { - dispatch_delete($path, $ep['callback']); - } + dispatch_all($path, strtolower($ep['method']), $ep['callback']); } } -function ServeApiDocs() { +function ServeApiDocs_v1() +{ set_include_path(get_include_path() . PATH_SEPARATOR . dirname(__DIR__)); extract($GLOBALS); include __DIR__ . '/api.php'; exit; } -function ServeApiHtml() { +function ServeApiDocs_v2() +{ + set_include_path(get_include_path() . PATH_SEPARATOR . dirname(__DIR__)); + extract($GLOBALS); + include __DIR__ . '/api.php'; + exit; +} + +function ServeApiHtml_v1() +{ + header('Content-Type: text/html; charset=utf-8'); + readfile(__DIR__ . '/v1/api.html'); + exit; +} + +function ServeApiHtml_v2() +{ header('Content-Type: text/html; charset=utf-8'); - readfile(__DIR__ . '/api.html'); + readfile(__DIR__ . '/v2/api.html'); exit; } -function ServeOpenApiSpec() { - $spec = json_decode(file_get_contents(__DIR__ . '/openapi.json'), true); +function ServeOpenApiSpec_v1() +{ + $spec = json_decode(file_get_contents(__DIR__ . '/v1/openapi.json'), true); + + foreach (collectPluginEndpoints() as $ep) { + $method = strtolower($ep['method']); + $path = '/api/plugin/' . $ep['plugin'] . '/' . $ep['endpoint']; + if (!isset($spec['paths'][$path])) { + $spec['paths'][$path] = array(); + } + if (!isset($spec['paths'][$path][$method])) { + $spec['paths'][$path][$method] = array( + 'summary' => $ep['plugin'] . ' - ' . $ep['endpoint'], + 'tags' => array('Plugins', $ep['plugin']), + 'responses' => array('200' => array('description' => 'Success')), + ); + } + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($spec, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + exit; +} + +function ServeOpenApiSpec_v2() +{ + $spec = json_decode(file_get_contents(__DIR__ . '/v2/openapi.json'), true); foreach (collectPluginEndpoints() as $ep) { $method = strtolower($ep['method']); - $path = '/plugin/' . $ep['plugin'] . '/' . $ep['endpoint']; + $path = '/api/v2/plugin/' . $ep['plugin'] . '/' . $ep['endpoint']; if (!isset($spec['paths'][$path])) { $spec['paths'][$path] = array(); } diff --git a/www/api/tools/build_docs.sh b/www/api/tools/build_docs.sh index fa6a9d935..6bee5391b 100644 --- a/www/api/tools/build_docs.sh +++ b/www/api/tools/build_docs.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash -# Regenerate openapi.json from @route PHPDoc annotations in controllers/*.php +# Regenerate versioned OpenAPI specs from @route PHPDoc annotations in controllers/*.php # Run from www/api/: bash tools/build_docs.sh set -e TOOLS="$(dirname "$0")" -python3 "$TOOLS/generate_openapi.py" -echo "✓ Lint with: npx @redocly/cli lint --config openapi.lint.yaml openapi.json" +python3 "$TOOLS/generate_openapi_v1.py" +python3 "$TOOLS/generate_openapi_v2.py" +echo "✓ Lint with: npx @redocly/cli lint --config openapi.lint.yaml v1/openapi.json" +echo "✓ Lint with: npx @redocly/cli lint --config openapi.lint.yaml v2/openapi.json" diff --git a/www/api/tools/convert_endpoints.py b/www/api/tools/convert_endpoints.py deleted file mode 100644 index d646191e9..000000000 --- a/www/api/tools/convert_endpoints.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python3 -"""One-time converter: endpoints.json -> openapi.yaml""" - -import json -import re -import sys -from pathlib import Path - -ROOT = Path(__file__).parent.parent -INPUT = ROOT / "endpoints.json" -OUTPUT = ROOT / "openapi.yaml" - - -def path_to_openapi(endpoint): - """Convert :Param to {Param} style.""" - return "/" + re.sub(r":([a-zA-Z_][a-zA-Z0-9_]*)", r"{\1}", endpoint) - - -def extract_params(path): - """Return list of {name, in, required, schema} for path params.""" - return [ - { - "name": p, - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - for p in re.findall(r"\{([^}]+)\}", path) - ] - - -def tag_from_endpoint(endpoint): - return endpoint.split("/")[0] - - -def yaml_scalar(value, indent=0): - """Render a Python value as a YAML scalar/block, indented.""" - prefix = " " * indent - if value is None: - return "null" - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (int, float)): - return str(value) - if isinstance(value, str): - # Multi-line or special chars -> block scalar - if "\n" in value or any(c in value for c in [':', '#', '{', '}', '[', ']', ',', '&', '*', '?', '|', '-', '<', '>', '=', '!', '%', '@', '`', '"', "'"]): - escaped = value.replace("'", "''") - return f"'{escaped}'" - return value - # Complex — use json.dumps as a quoted string - return "'" + json.dumps(value).replace("'", "''") + "'" - - -def needs_quoting(k): - return any(c in str(k) for c in ': #{}[]!*&|>\'"%@`') - - -def to_yaml_lines(value, indent): - """Recursively emit a Python value as YAML lines at the given indent depth.""" - prefix = " " * indent - lines = [] - if isinstance(value, dict): - for k, v in value.items(): - k_safe = f"'{k}'" if needs_quoting(k) else k - if isinstance(v, (dict, list)): - lines.append(f"{prefix}{k_safe}:") - lines.extend(to_yaml_lines(v, indent + 1)) - elif isinstance(v, str) and '\n' in v: - lines.append(f"{prefix}{k_safe}: |") - for subline in v.splitlines(): - lines.append(f"{prefix} {subline}") - else: - lines.append(f"{prefix}{k_safe}: {yaml_scalar(v)}") - elif isinstance(value, list): - for item in value: - if isinstance(item, (dict, list)): - sub = to_yaml_lines(item, indent + 1) - first = sub[0] if sub else f"{' ' * (indent + 1)}" - lines.append(f"{prefix}- {first.lstrip()}") - lines.extend(sub[1:]) - else: - lines.append(f"{prefix}- {yaml_scalar(item)}") - return lines - - -def yaml_example_block(value, indent): - """Render a value as a native YAML 'example' block at the given indent level.""" - prefix = " " * indent - if isinstance(value, (dict, list)): - inner = to_yaml_lines(value, indent + 1) - return f"{prefix}example:\n" + "\n".join(inner) - else: - return f"{prefix}example: {yaml_scalar(value)}" - - -def build_openapi(data): - endpoints = data["endpoints"] - # Collect all tags - tags = sorted(set(tag_from_endpoint(e["endpoint"]) for e in endpoints)) - - lines = [] - - # Header - lines += [ - "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:", - ] - for tag in tags: - lines.append(f" - name: {tag}") - lines.append("") - lines.append("paths:") - - for entry in endpoints: - endpoint = entry["endpoint"] - oapi_path = path_to_openapi(endpoint) - params = extract_params(oapi_path) - tag = tag_from_endpoint(endpoint) - methods = entry.get("methods", {}) - - lines.append(f" '{oapi_path}':") - - # If path params are shared across methods, emit once at path level - if params: - lines.append(" parameters:") - for p in params: - lines.append(f" - name: {p['name']}") - lines.append(f" in: path") - lines.append(f" required: true") - lines.append(f" schema:") - lines.append(f" type: string") - - for method, spec in methods.items(): - http_method = method.lower() - desc = spec.get("desc", "") - inp = spec.get("input") - out = spec.get("output") - - lines.append(f" {http_method}:") - lines.append(f" tags:") - lines.append(f" - {tag}") - lines.append(f" summary: '{endpoint}'") - if desc: - desc_safe = desc.replace("'", "''") - lines.append(f" description: '{desc_safe}'") - - # Request body - if inp is not None: - lines.append(f" requestBody:") - lines.append(f" content:") - if isinstance(inp, str): - lines.append(f" text/plain:") - lines.append(f" schema:") - lines.append(f" type: string") - inp_safe = inp.replace("'", "''") - lines.append(f" example: '{inp_safe}'") - else: - lines.append(f" application/json:") - lines.append(f" schema:") - lines.append(f" type: object") - lines.append(yaml_example_block(inp, indent=6)) - - # Response - lines.append(f" responses:") - lines.append(f" '200':") - if out is None: - lines.append(f" description: Success") - elif isinstance(out, str): - out_safe = out.replace("'", "''") - lines.append(f" description: '{out_safe}'") - else: - lines.append(f" description: Success") - lines.append(f" content:") - lines.append(f" application/json:") - lines.append(f" schema:") - if isinstance(out, list): - lines.append(f" type: array") - lines.append(f" items: {{}}") - else: - lines.append(f" type: object") - lines.append(yaml_example_block(out, indent=7)) - - return "\n".join(lines) + "\n" - - -def main(): - with open(INPUT) as f: - data = json.load(f) - - output = build_openapi(data) - with open(OUTPUT, "w") as f: - f.write(output) - - count = len(data["endpoints"]) - print(f"Written {OUTPUT} ({count} endpoints)") - - -if __name__ == "__main__": - main() diff --git a/www/api/tools/generate_openapi.py b/www/api/tools/generate_openapi.py deleted file mode 100644 index 6422a0cf4..000000000 --- a/www/api/tools/generate_openapi.py +++ /dev/null @@ -1,422 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate www/api/openapi.json from @route/@body/@response 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. -""" - -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 / 'openapi.json' - -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, -) - - -# --------------------------------------------------------------------------- -# 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.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 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': '/', '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_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 new file mode 100644 index 000000000..91eff8dee --- /dev/null +++ b/www/api/tools/generate_openapi_v1.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +""" +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_v1.py +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from generate_openapi_base import main # noqa: E402 + +main( + version=1, + output='v1/openapi.json', + server_url='/api/', + server_desc='Local FPP instance', + info_version='1.0', +) diff --git a/www/api/tools/generate_openapi_v2.py b/www/api/tools/generate_openapi_v2.py new file mode 100644 index 000000000..34609ef6f --- /dev/null +++ b/www/api/tools/generate_openapi_v2.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +""" +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 +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from generate_openapi_base import main # noqa: E402 + +main( + version=2, + output='v2/openapi.json', + server_url='/api/v2', + server_desc='Local FPP instance (v2)', + info_version='2.0', +) diff --git a/www/api/v1/api.html b/www/api/v1/api.html new file mode 100644 index 000000000..1edc98f47 --- /dev/null +++ b/www/api/v1/api.html @@ -0,0 +1,36 @@ + + + + + + FPP API + + + + + + + diff --git a/www/api/openapi.json b/www/api/v1/openapi.json similarity index 73% rename from www/api/openapi.json rename to www/api/v1/openapi.json index def07a0cb..c6c117fce 100644 --- a/www/api/openapi.json +++ b/www/api/v1/openapi.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "/", + "url": "/api/", "description": "Local FPP instance" } ], @@ -21,15 +21,6 @@ { "name": "channel" }, - { - "name": "command" - }, - { - "name": "commandPresets" - }, - { - "name": "commands" - }, { "name": "configfile" }, @@ -51,42 +42,21 @@ { "name": "files" }, - { - "name": "fppd" - }, - { - "name": "geoip" - }, { "name": "git" }, - { - "name": "gpio" - }, - { - "name": "help" - }, { "name": "media" }, - { - "name": "models" - }, { "name": "network" }, { "name": "options" }, - { - "name": "overlays" - }, { "name": "pipewire" }, - { - "name": "player" - }, { "name": "playlist" }, @@ -102,9 +72,6 @@ { "name": "proxy" }, - { - "name": "recurringtasks" - }, { "name": "remoteAction" }, @@ -134,13 +101,10 @@ }, { "name": "time" - }, - { - "name": "variables" } ], "paths": { - "/api/backups/configuration": { + "/backups/configuration": { "post": { "tags": [ "backups" @@ -176,7 +140,7 @@ } } }, - "/api/backups/configuration/list": { + "/backups/configuration/list": { "get": { "tags": [ "backups" @@ -215,7 +179,7 @@ } } }, - "/api/backups/configuration/list/{DeviceName}": { + "/backups/configuration/list/{DeviceName}": { "parameters": [ { "name": "DeviceName", @@ -252,7 +216,7 @@ } } }, - "/api/backups/configuration/restore/{Directory}/{BackupFilename}": { + "/backups/configuration/restore/{Directory}/{BackupFilename}": { "parameters": [ { "name": "Directory", @@ -314,7 +278,7 @@ } } }, - "/api/backups/configuration/{Directory}/{BackupFilename}": { + "/backups/configuration/{Directory}/{BackupFilename}": { "parameters": [ { "name": "Directory", @@ -395,7 +359,7 @@ } } }, - "/api/backups/devices": { + "/backups/devices": { "get": { "tags": [ "backups" @@ -424,7 +388,7 @@ } } }, - "/api/backups/devices/mount/{DeviceName}/{MountLocation}": { + "/backups/devices/mount/{DeviceName}/{MountLocation}": { "parameters": [ { "name": "DeviceName", @@ -468,7 +432,7 @@ } } }, - "/api/backups/devices/unmount/{DeviceName}/{MountLocation}": { + "/backups/devices/unmount/{DeviceName}/{MountLocation}": { "parameters": [ { "name": "DeviceName", @@ -512,7 +476,7 @@ } } }, - "/api/backups/list": { + "/backups/list": { "get": { "tags": [ "backups" @@ -538,7 +502,7 @@ } } }, - "/api/backups/list/{DeviceName}": { + "/backups/list/{DeviceName}": { "parameters": [ { "name": "DeviceName", @@ -574,7 +538,7 @@ } } }, - "/api/cape": { + "/cape": { "get": { "tags": [ "cape" @@ -643,7 +607,7 @@ } } }, - "/api/cape/eeprom/sign/{key}/{order}": { + "/cape/eeprom/sign/{key}/{order}": { "parameters": [ { "name": "key", @@ -686,7 +650,7 @@ } } }, - "/api/cape/eeprom/signingData": { + "/cape/eeprom/signingData": { "post": { "tags": [ "cape" @@ -726,7 +690,7 @@ } } }, - "/api/cape/eeprom/signingData/{key}/{order}": { + "/cape/eeprom/signingData/{key}/{order}": { "parameters": [ { "name": "key", @@ -771,7 +735,7 @@ } } }, - "/api/cape/eeprom/signingFile/{key}/{order}": { + "/cape/eeprom/signingFile/{key}/{order}": { "parameters": [ { "name": "key", @@ -811,7 +775,7 @@ } } }, - "/api/cape/eeprom/voucher": { + "/cape/eeprom/voucher": { "post": { "tags": [ "cape" @@ -854,7 +818,7 @@ } } }, - "/api/cape/options": { + "/cape/options": { "get": { "tags": [ "cape" @@ -884,7 +848,7 @@ } } }, - "/api/cape/panel": { + "/cape/panel": { "get": { "tags": [ "cape" @@ -906,7 +870,7 @@ } } }, - "/api/cape/panel/{key}": { + "/cape/panel/{key}": { "parameters": [ { "name": "key", @@ -938,7 +902,7 @@ } } }, - "/api/cape/strings": { + "/cape/strings": { "get": { "tags": [ "cape" @@ -963,7 +927,7 @@ } } }, - "/api/cape/strings/{key}": { + "/cape/strings/{key}": { "parameters": [ { "name": "key", @@ -1026,7 +990,7 @@ } } }, - "/api/channel/input/stats": { + "/channel/input/stats": { "delete": { "tags": [ "channel" @@ -1093,7 +1057,7 @@ } } }, - "/api/channel/output/processors": { + "/channel/output/processors": { "get": { "tags": [ "channel" @@ -1183,7 +1147,7 @@ } } }, - "/api/channel/output/{file}": { + "/channel/output/{file}": { "parameters": [ { "name": "file", @@ -1258,187 +1222,7 @@ } } }, - "/api/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." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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`)." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/configfile": { + "/configfile": { "get": { "tags": [ "configfile" @@ -1467,7 +1251,7 @@ } } }, - "/api/configfile/**": { + "/configfile/**": { "delete": { "tags": [ "configfile" @@ -1545,7 +1329,7 @@ } } }, - "/api/dir/{DirName}/{SubDir}": { + "/dir/{DirName}/{SubDir}": { "parameters": [ { "name": "DirName", @@ -1613,7 +1397,7 @@ } } }, - "/api/effects": { + "/effects": { "get": { "tags": [ "effects" @@ -1638,7 +1422,7 @@ } } }, - "/api/effects/ALL": { + "/effects/ALL": { "get": { "tags": [ "effects" @@ -1664,7 +1448,7 @@ } } }, - "/api/email/configure": { + "/email/configure": { "post": { "tags": [ "email" @@ -1689,7 +1473,7 @@ } } }, - "/api/email/test": { + "/email/test": { "post": { "tags": [ "email" @@ -1714,7 +1498,7 @@ } } }, - "/api/events": { + "/events": { "get": { "tags": [ "events" @@ -1742,7 +1526,7 @@ } } }, - "/api/events/{eventId}": { + "/events/{eventId}": { "parameters": [ { "name": "eventId", @@ -1778,7 +1562,7 @@ } } }, - "/api/events/{eventId}/trigger": { + "/events/{eventId}/trigger": { "parameters": [ { "name": "eventId", @@ -1795,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", @@ -1812,7 +1602,7 @@ } } }, - "/api/file/info/{plugin}/{ext}/**": { + "/file/info/{plugin}/{ext}/**": { "parameters": [ { "name": "plugin", @@ -1852,7 +1642,7 @@ } } }, - "/api/file/move/{fileName}": { + "/file/move/{fileName}": { "parameters": [ { "name": "fileName", @@ -1869,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", @@ -1886,7 +1682,7 @@ } } }, - "/api/file/onUpload/{ext}/**": { + "/file/onUpload/{ext}/**": { "parameters": [ { "name": "ext", @@ -1903,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", @@ -1920,7 +1722,7 @@ } } }, - "/api/file/{DirName}": { + "/file/{DirName}": { "parameters": [ { "name": "DirName", @@ -1957,7 +1759,7 @@ } } }, - "/api/file/{DirName}/**": { + "/file/{DirName}/**": { "parameters": [ { "name": "DirName", @@ -2053,7 +1855,7 @@ } } }, - "/api/file/{DirName}/copy/{source}/{dest}": { + "/file/{DirName}/copy/{source}/{dest}": { "parameters": [ { "name": "DirName", @@ -2105,7 +1907,7 @@ } } }, - "/api/file/{DirName}/rename/{source}/{dest}": { + "/file/{DirName}/rename/{source}/{dest}": { "parameters": [ { "name": "DirName", @@ -2157,7 +1959,7 @@ } } }, - "/api/file/{DirName}/tailfollow/*": { + "/file/{DirName}/tailfollow/*": { "parameters": [ { "name": "DirName", @@ -2198,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." } } }, @@ -2222,7 +2024,7 @@ } } }, - "/api/file/{DirName}/{Name}": { + "/file/{DirName}/{Name}": { "parameters": [ { "name": "DirName", @@ -2286,32 +2088,7 @@ } } }, - "/api/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 - } - } - } - } - } - } - }, - "/api/files/zip/{DirNames}": { + "/files/zip/{DirNames}": { "parameters": [ { "name": "DirNames", @@ -2343,7 +2120,7 @@ } } }, - "/api/files/{DirName}": { + "/files/{DirName}": { "parameters": [ { "name": "DirName", @@ -2397,1431 +2174,44 @@ } } }, - "/api/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" - } - } - } - }, - "/api/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" + ] + } + } } } } }, - "/api/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." - } - } - } - }, - "/api/fppd/effects/{name}": { - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/effects/{name}", - "description": "Start (or update) a named overlay effect on the player.", - "responses": { - "200": { - "description": "Effect started." - } - } - } - }, - "/api/fppd/falcon/hardware": { - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/falcon/hardware", - "description": "Re-read the Falcon hardware (e.g. cape/receiver) configuration.", - "responses": { - "200": { - "description": "Hardware refreshed." - } - } - } - }, - "/api/fppd/gpio/ext": { - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/gpio/ext", - "description": "Set an external GPIO input state.", - "responses": { - "200": { - "description": "GPIO state updated." - } - } - } - }, - "/api/fppd/log": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/log", - "description": "Get the current fppd logging configuration (log level and enabled channels).", - "responses": { - "200": { - "description": "Current log settings." - } - } - } - }, - "/api/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": { - "tags": [ - "fppd" - ], - "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).", - "responses": { - "200": { - "description": "Log level updated." - }, - "400": { - "description": "Invalid or unrecognized log level." - } - } - } - }, - "/api/fppd/mqtt/cache": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/mqtt/cache", - "description": "Dump the cached MQTT messages.", - "responses": { - "200": { - "description": "Object keyed by topic, each value the topic's last cached message as a plain string." - }, - "400": { - "description": "MQTT is not initialized." - } - } - } - }, - "/api/fppd/multiSyncStats": { - "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." - } - ], - "responses": { - "200": { - "description": "MultiSync statistics." - } - } - } - }, - "/api/fppd/multiSyncSystems": { - "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." - } - ], - "responses": { - "200": { - "description": "MultiSync systems." - } - } - } - }, - "/api/fppd/outputs": { - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/outputs", - "description": "Apply a new channel-output configuration.", - "responses": { - "200": { - "description": "Outputs updated." - } - } - } - }, - "/api/fppd/outputs/remap": { - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/outputs/remap", - "description": "Remap channel outputs.", - "responses": { - "200": { - "description": "Outputs remapped." - } - } - } - }, - "/api/fppd/playlist/config": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/playlist/config", - "description": "Get the configuration of the running playlist.", - "responses": { - "200": { - "description": "Playlist configuration." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/fppd/playlists": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/playlists", - "description": "List the playlists that are currently running.", - "responses": { - "200": { - "description": "Currently running playlists." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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" - ] - } - } - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/fppd/sequence": { - "get": { - "tags": [ - "fppd" - ], - "summary": "fppd/sequence", - "description": "Get the list of running sequences.", - "responses": { - "200": { - "description": "Running sequences." - } - } - } - }, - "/api/fppd/shutdown": { - "post": { - "tags": [ - "fppd" - ], - "summary": "fppd/shutdown", - "description": "Shut down the fppd daemon.", - "responses": { - "200": { - "description": "fppd shutting down." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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" - ] - } - } - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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" - } - } - } - } - } - } - }, - "/api/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)." - } - } - } - }, - "/api/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`." - } - } - } - }, - "/api/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" - ] - } - } - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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" - } - } - } - } - } - } - }, - "/api/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" - ] - } - } - } - } - } - }, - "/api/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()" - } - ] - } - } - } - } - } - } - }, - "/api/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" - } - } - } - }, - "/api/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 - } - ] - } - } - } - } - } - } - }, - "/api/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" - ] - } - } - } - } - } - }, - "/api/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." - ] - } - } - } - } - } - } - }, - "/api/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'." - } - } - } - } - } - } - }, - "/api/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`)." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/help": { - "get": { - "tags": [ - "help" - ], - "summary": "help", - "description": "Returns an HTML page listing all available FPP API endpoints and their descriptions.", - "responses": { - "200": { - "description": "HTML page listing all API endpoints", - "content": { - "text/html": { - "schema": { - "type": "string" - }, - "example": "" - } - } - } - } - } - }, - "/api/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" - ] - } - } - } - } - } - }, - "/api/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}" - } - } - } - } - } - }, - "/api/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" - } - ] - } - } - } - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/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" - } - } - } - } - } - } - } - }, - "/api/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", + "description": "Commits in origin not yet in local", "content": { "application/json": { "schema": { @@ -3829,72 +2219,29 @@ }, "example": { "status": "OK", - "GATEWAY": "192.168.1.1" - } - } - } - } - } - } - }, - "/api/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" + "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()" } - } - ] + ] + } } } } } } }, - "/api/network/interface/add/{interface}": { + "/git/releases/os/{All}": { "parameters": [ { - "name": "interface", + "name": "All", "in": "path", "required": true, "schema": { @@ -3904,20 +2251,36 @@ ], "get": { "tags": [ - "network" + "git" ], - "summary": "Create DHCP interface", - "description": "Creates a new blank DHCP interface configuration file for the specified network interface (e.g. `eth1`, `wlan0`).", + "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": "DHCP interface created", + "description": "Available OS release assets", "content": { "application/json": { "schema": { "type": "object" }, "example": { - "status": "New Blank Interface created" + "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 + } + ] } } } @@ -3925,104 +2288,47 @@ } } }, - "/api/network/interface/{interface}": { - "parameters": [ - { - "name": "interface", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], + "/git/releases/sizes": { "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" + "git" ], - "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" - } - } - } - }, + "summary": "Get release asset sizes", + "description": "Returns release asset size information from the GitHub `FalconChristmas/fpp` releases API.", "responses": { "200": { - "description": "Interface configuration saved", + "description": "Release asset sizes", "content": { "application/json": { "schema": { - "type": "object" + "type": "array" }, - "example": { - "status": "OK" - } - } - } - } - } - } - }, - "/api/network/interface/{interface}/apply": { - "parameters": [ - { - "name": "interface", - "in": "path", - "required": true, - "schema": { - "type": "string" + "example": [ + "BBB-nightly_2026-05.fppos,1161494528", + "BBB-10.0-alpha_2026-02.fppos,884658176" + ] + } + } } } - ], - "post": { + } + }, + "/git/reset": { + "get": { "tags": [ - "network" + "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": "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": "Reset complete", "content": { "application/json": { "schema": { @@ -4030,7 +2336,11 @@ }, "example": { "status": "OK", - "output": [] + "log": [ + "HEAD is now at a1b65d43 Git Reset moved - #944", + "Entering 'external/RF24'", + "HEAD is now at ebc3abe Fix typo, missing space." + ] } } } @@ -4038,57 +2348,60 @@ } } }, - "/api/network/persistentNames": { - "delete": { + "/git/status": { + "get": { "tags": [ - "network" + "git" ], - "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 local repo status", + "description": "Returns the status of the local git branch, including any dirty files.", "responses": { "200": { - "description": "Persistent names removed", + "description": "Local repository status", "content": { "application/json": { "schema": { "type": "object" }, "example": { - "status": "OK" + "status": "OK", + "log": "On branch master\nYour branch is up to date with 'origin/master'." } } } } } - }, - "post": { + } + }, + "/media": { + "get": { "tags": [ - "network" + "media" ], - "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.", + "summary": "List all media files", + "description": "Returns a list of media files (includes both music and video files).", "responses": { "200": { - "description": "Persistent names created", + "description": "List of media filenames", "content": { "application/json": { "schema": { - "type": "object" + "type": "array" }, - "example": { - "status": "OK", - "interfaceCnt": 2 - } + "example": [ + "Frosty.mp4", + "Jingle_Bells.mp3" + ] } } } } } }, - "/api/network/wifi/scan/{interface}": { + "/media/{MediaName}/duration": { "parameters": [ { - "name": "interface", + "name": "MediaName", "in": "path", "required": true, "schema": { @@ -4098,39 +2411,44 @@ ], "get": { "tags": [ - "network" + "media" ], - "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.", + "summary": "Get duration of media item", + "description": "Returns the duration of a media item.", "responses": { "200": { - "description": "Discoverable Wi-Fi networks", + "description": "Media duration", "content": { "application/json": { "schema": { "type": "object" }, "example": { - "status": "OK", - "networks": [ - { - "lastSeen": "0 ms ago", - "freq": 2437, - "signal": "-61.00 dBm", - "SSID": "Christmas" - } - ] + "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}" + } + } } } } }, - "/api/network/wifi/status/{interface}": { + "/media/{MediaName}/meta": { "parameters": [ { - "name": "interface", + "name": "MediaName", "in": "path", "required": true, "schema": { @@ -4140,28 +2458,30 @@ ], "get": { "tags": [ - "network" + "media" ], - "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).", + "summary": "Get metadata for media item", + "description": "Returns metadata streams, codecs, profiles, type for a specific media file.", "responses": { "200": { - "description": "WiFi connection status", + "description": "Media file metadata", "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)." + "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" + } + ] } } } @@ -4169,62 +2489,64 @@ } } }, - "/api/network/wifi/strength": { + "/network/dns": { "get": { "tags": [ "network" ], - "summary": "Get all wifi signal strenths", - "description": "Returns signal strength information for wireless network interfaces.", + "summary": "Get DNS configuration", + "description": "Returns the current DNS configuration. If not configured, `status` will be `Not Configured`.", "responses": { "200": { - "description": "Wi-Fi signal strength per interface", + "description": "Current DNS configuration", "content": { "application/json": { "schema": { - "type": "array" + "type": "object" }, - "example": [ - { - "interface": "wlan0", - "link": 45, - "level": -65, - "noise": -256 - } - ] + "example": { + "DNS1": "192.168.50.1", + "DNS2": "192.168.1.1", + "status": "OK" + } } } } } - } - }, - "/api/options/{SettingName}": { - "parameters": [ - { - "name": "SettingName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "get": { + }, + "post": { "tags": [ - "options" + "network" ], - "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.", + "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": "Available options for the setting", + "description": "DNS configuration updated", "content": { "application/json": { "schema": { "type": "object" }, "example": { - "Dummy": "0" + "status": "OK", + "DNS": { + "DNS1": "192.168.50.1", + "DNS2": "192.168.1.1" + } } } } @@ -4232,121 +2554,123 @@ } } }, - "/api/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`)." - } - } - } - }, - "/api/overlays/effects/{effect}": { - "parameters": [ - { - "name": "effect", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], + "/network/gateway": { "get": { "tags": [ - "overlays" + "network" ], - "summary": "overlays/effects/{effect}", - "description": "Get the description of a single overlay effect.", + "summary": "Get default gateway", + "description": "Returns the currently configured default gateway IP address. May be empty when using DHCP.", "responses": { "200": { - "description": "The effect description." + "description": "Current default gateway", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "GATEWAY": "192.168.1.1" + } + } + } } } - } - }, - "/api/overlays/fonts": { - "get": { + }, + "post": { "tags": [ - "overlays" + "network" ], - "summary": "overlays/fonts", - "description": "List the fonts available for overlay text effects.", - "responses": { - "200": { - "description": "Array of font names." - } - } - } - }, - "/api/overlays/model/{model}": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" + "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" + } + } } - } - ], - "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." + "description": "Default gateway saved", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "GATEWAY": "192.168.1.1" + } + } + } } } } }, - "/api/overlays/model/{model}/clear": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], + "/network/interface": { "get": { "tags": [ - "overlays" + "network" ], - "summary": "overlays/model/{model}/clear", - "description": "Clear (blank) an overlay model's pixel buffer.", + "summary": "Get network interface details", + "description": "Returns detailed information about network interfaces, their IP addresses, and Wi-Fi signal strength.", "responses": { "200": { - "description": "Model cleared." + "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" + } + } + ] + } + } } } } }, - "/api/overlays/model/{model}/data": { + "/network/interface/add/{interface}": { "parameters": [ { - "name": "model", + "name": "interface", "in": "path", "required": true, "schema": { @@ -4356,45 +2680,37 @@ ], "get": { "tags": [ - "overlays" + "network" ], - "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)." - } - } - } - }, - "/api/overlays/model/{model}/fill": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" + "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" } - } - ], - "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." + "description": "DHCP interface created", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "New Blank Interface created" + } + } + } } } } }, - "/api/overlays/model/{model}/mmap": { + "/network/interface/{interface}": { "parameters": [ { - "name": "model", + "name": "interface", "in": "path", "required": true, "schema": { @@ -4402,47 +2718,77 @@ } } ], - "put": { + "get": { "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." - } - } - } - }, - "/api/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]}`.", + "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" + } + } + } + }, "responses": { "200": { - "description": "Pixel set." + "description": "Interface configuration saved", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } } } } }, - "/api/overlays/model/{model}/preview": { + "/network/interface/{interface}/apply": { "parameters": [ { - "name": "model", + "name": "interface", "in": "path", "required": true, "schema": { @@ -4450,71 +2796,81 @@ } } ], - "get": { + "post": { "tags": [ - "overlays" + "network" ], - "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.", + "summary": "Set networking configuration", + "description": "Applies the networking settings for the specified `{interface}` at the OS level and restarts the interface.", "responses": { "200": { - "description": "Object with a `pixels` array of [x, y, channel] triples." + "description": "Networking configuration applied", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "output": [] + } + } + } } } } }, - "/api/overlays/model/{model}/save": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "put": { + "/network/persistentNames": { + "delete": { "tags": [ - "overlays" + "network" ], - "summary": "overlays/model/{model}/save", - "description": "Save an overlay model's current buffer to an image file. Body: `{\"File\":\"name\"}`.", + "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": "Overlay saved as image." - } - } - } - }, - "/api/overlays/model/{model}/state": { - "parameters": [ - { - "name": "model", - "in": "path", - "required": true, - "schema": { - "type": "string" + "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 + } + } + } } } } }, - "/api/overlays/model/{model}/text": { + "/network/wifi/scan/{interface}": { "parameters": [ { - "name": "model", + "name": "interface", "in": "path", "required": true, "schema": { @@ -4522,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" + } + ] + } + } + } } } } }, - "/api/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 + } + ] + } + } } } } }, - "/api/overlays/range/{ranges}": { + "/options/{SettingName}": { "parameters": [ { - "name": "ranges", + "name": "SettingName", "in": "path", "required": true, "schema": { @@ -4560,48 +2949,30 @@ } } ], - "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." - } - } - } - }, - "/api/overlays/running": { - "get": { - "tags": [ - "overlays" - ], - "summary": "overlays/running", - "description": "List the overlay effects that are currently running.", - "responses": { - "200": { - "description": "Active overlay effects." - } - } - } - }, - "/api/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" + } + } + } } } } }, - "/api/pipewire/control/groups": { + "/pipewire/control/groups": { "get": { "tags": [ "pipewire" @@ -4653,7 +3024,7 @@ } } }, - "/api/pipewire/control/groups/{id}": { + "/pipewire/control/groups/{id}": { "parameters": [ { "name": "id", @@ -4700,7 +3071,7 @@ } } }, - "/api/pipewire/control/groups/{id}/members/{cardId}/mute": { + "/pipewire/control/groups/{id}/members/{cardId}/mute": { "parameters": [ { "name": "id", @@ -4768,7 +3139,7 @@ } } }, - "/api/pipewire/control/groups/{id}/members/{cardId}/volume": { + "/pipewire/control/groups/{id}/members/{cardId}/volume": { "parameters": [ { "name": "id", @@ -4837,7 +3208,7 @@ } } }, - "/api/pipewire/control/groups/{id}/mute": { + "/pipewire/control/groups/{id}/mute": { "parameters": [ { "name": "id", @@ -4896,7 +3267,7 @@ } } }, - "/api/pipewire/control/groups/{id}/volume": { + "/pipewire/control/groups/{id}/volume": { "parameters": [ { "name": "id", @@ -4956,7 +3327,7 @@ } } }, - "/api/pipewire/control/input-groups": { + "/pipewire/control/input-groups": { "get": { "tags": [ "pipewire" @@ -5004,7 +3375,7 @@ } } }, - "/api/pipewire/control/input-groups/{id}": { + "/pipewire/control/input-groups/{id}": { "parameters": [ { "name": "id", @@ -5051,7 +3422,7 @@ } } }, - "/api/pipewire/control/input-groups/{id}/members/{memberIndex}/mute": { + "/pipewire/control/input-groups/{id}/members/{memberIndex}/mute": { "parameters": [ { "name": "id", @@ -5118,7 +3489,7 @@ } } }, - "/api/pipewire/control/input-groups/{id}/members/{memberIndex}/volume": { + "/pipewire/control/input-groups/{id}/members/{memberIndex}/volume": { "parameters": [ { "name": "id", @@ -5186,7 +3557,7 @@ } } }, - "/api/pipewire/control/routing": { + "/pipewire/control/routing": { "get": { "tags": [ "pipewire" @@ -5234,7 +3605,7 @@ } } }, - "/api/pipewire/control/routing/{inputGroupId}/{outputGroupId}/mute": { + "/pipewire/control/routing/{inputGroupId}/{outputGroupId}/mute": { "parameters": [ { "name": "inputGroupId", @@ -5298,7 +3669,7 @@ } } }, - "/api/pipewire/control/routing/{inputGroupId}/{outputGroupId}/volume": { + "/pipewire/control/routing/{inputGroupId}/{outputGroupId}/volume": { "parameters": [ { "name": "inputGroupId", @@ -5363,7 +3734,7 @@ } } }, - "/api/pipewire/control/status": { + "/pipewire/control/status": { "get": { "tags": [ "pipewire" @@ -5399,7 +3770,7 @@ } } }, - "/api/pipewire/control/streams": { + "/pipewire/control/streams": { "get": { "tags": [ "pipewire" @@ -5439,7 +3810,7 @@ } } }, - "/api/pipewire/control/streams/{slot}/volume": { + "/pipewire/control/streams/{slot}/volume": { "parameters": [ { "name": "slot", @@ -5495,49 +3866,7 @@ } } }, - "/api/player": { - "get": { - "tags": [ - "player" - ], - "summary": "player", - "description": "Get the player status. Equivalent to /api/player/status.", - "responses": { - "200": { - "description": "Player status JSON." - } - } - } - }, - "/api/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." - } - } - } - }, - "/api/player/status": { - "get": { - "tags": [ - "player" - ], - "summary": "player/status", - "description": "Get the player status (playlist, sequence, timing, mode, etc.).", - "responses": { - "200": { - "description": "Player status JSON." - } - } - } - }, - "/api/playlist/{PlaylistName}": { + "/playlist/{PlaylistName}": { "parameters": [ { "name": "PlaylistName", @@ -5678,7 +4007,7 @@ } } }, - "/api/playlist/{PlaylistName}/start": { + "/playlist/{PlaylistName}/start": { "parameters": [ { "name": "PlaylistName", @@ -5699,6 +4028,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -5719,7 +4052,7 @@ } } }, - "/api/playlist/{PlaylistName}/start/{Repeat}": { + "/playlist/{PlaylistName}/start/{Repeat}": { "parameters": [ { "name": "PlaylistName", @@ -5748,6 +4081,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "parameters": [ @@ -5779,7 +4116,7 @@ } } }, - "/api/playlist/{PlaylistName}/start/{Repeat}/{ScheduleProtected}": { + "/playlist/{PlaylistName}/start/{Repeat}/{ScheduleProtected}": { "parameters": [ { "name": "PlaylistName", @@ -5816,6 +4153,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -5836,7 +4177,7 @@ } } }, - "/api/playlist/{PlaylistName}/{SectionName}/item": { + "/playlist/{PlaylistName}/{SectionName}/item": { "parameters": [ { "name": "PlaylistName", @@ -5894,7 +4235,7 @@ } } }, - "/api/playlists": { + "/playlists": { "get": { "tags": [ "playlists" @@ -5980,7 +4321,7 @@ } } }, - "/api/playlists/pause": { + "/playlists/pause": { "get": { "tags": [ "playlists" @@ -5991,6 +4332,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -6011,7 +4356,7 @@ } } }, - "/api/playlists/playable": { + "/playlists/playable": { "get": { "tags": [ "playlists" @@ -6037,7 +4382,7 @@ } } }, - "/api/playlists/resume": { + "/playlists/resume": { "get": { "tags": [ "playlists" @@ -6048,6 +4393,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -6068,7 +4417,7 @@ } } }, - "/api/playlists/stop": { + "/playlists/stop": { "get": { "tags": [ "playlists" @@ -6079,6 +4428,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -6099,7 +4452,7 @@ } } }, - "/api/playlists/stopgracefully": { + "/playlists/stopgracefully": { "get": { "tags": [ "playlists" @@ -6110,6 +4463,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -6130,7 +4487,7 @@ } } }, - "/api/playlists/stopgracefullyafterloop": { + "/playlists/stopgracefullyafterloop": { "get": { "tags": [ "playlists" @@ -6141,6 +4498,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -6161,7 +4522,7 @@ } } }, - "/api/playlists/validate": { + "/playlists/validate": { "get": { "tags": [ "playlists" @@ -6196,7 +4557,7 @@ } } }, - "/api/plugin": { + "/plugin": { "get": { "tags": [ "plugin" @@ -6262,24 +4623,7 @@ } } }, - "/api/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" - } - } - } - }, - "/api/plugin/fetchInfo": { + "/plugin/fetchInfo": { "post": { "tags": [ "plugin" @@ -6314,48 +4658,7 @@ } } }, - "/api/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" - } - } - } - } - } - } - }, - "/api/plugin/headerIndicators": { + "/plugin/headerIndicators": { "get": { "tags": [ "plugin" @@ -6377,137 +4680,13 @@ "color": "red" } ] - } - } - } - } - } - }, - "/api/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" - } - } - } - } - } - } - }, - "/api/plugin/{RepoName}": { - "parameters": [ - { - "name": "RepoName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "delete": { - "tags": [ - "plugin" - ], - "summary": "Uninstall plugin", - "description": "Uninstall plugin {RepoName}.", - "responses": { - "200": { - "description": "Plugin uninstalled", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "Status": "OK", - "Message": "" - } - } - } - } - } - }, - "get": { - "tags": [ - "plugin" - ], - "summary": "Get plugin information", - "description": "Get `pluginInfo.json` for installed plugin `{RepoName}`. An additional `updatesAvailable` field indicates whether the plugin has commits that have been fetched but not yet merged.", - "responses": { - "200": { - "description": "Plugin information", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "repoName": "fpp-matrixtools", - "name": "MatrixTools", - "author": "Chris Pinkham (CaptainMurdoch)", - "srcURL": "https://github.com/cpinkham/fpp-matrixtools.git", - "updatesAvailable": 0, - "versions": [ - { - "minFPPVersion": 0, - "maxFPPVersion": 0, - "branch": "master", - "sha": "" - } - ] - } - } - } - } - } - } - }, - "/api/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" + } + } } } } }, - "/api/plugin/{RepoName}/page": { + "/plugin/{RepoName}": { "parameters": [ { "name": "RepoName", @@ -6518,24 +4697,57 @@ } } ], + "delete": { + "tags": [ + "plugin" + ], + "summary": "Uninstall plugin", + "description": "Uninstall plugin {RepoName}.", + "responses": { + "200": { + "description": "Plugin uninstalled", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + }, "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.", + "summary": "Get plugin information", + "description": "Get `pluginInfo.json` for installed plugin `{RepoName}`. An additional `updatesAvailable` field indicates whether the plugin has commits that have been fetched but not yet merged.", "responses": { "200": { - "description": "Plugin page info", + "description": "Plugin information", "content": { "application/json": { "schema": { "type": "object" }, "example": { - "url": "plugin.php?plugin=fpp-matrixtools&page=status.php", - "page": "status.php", - "found": true + "repoName": "fpp-matrixtools", + "name": "MatrixTools", + "author": "Chris Pinkham (CaptainMurdoch)", + "srcURL": "https://github.com/cpinkham/fpp-matrixtools.git", + "updatesAvailable": 0, + "versions": [ + { + "minFPPVersion": 0, + "maxFPPVersion": 0, + "branch": "master", + "sha": "" + } + ] } } } @@ -6543,7 +4755,7 @@ } } }, - "/api/plugin/{RepoName}/settings/{SettingName}": { + "/plugin/{RepoName}/settings/{SettingName}": { "parameters": [ { "name": "RepoName", @@ -6619,7 +4831,7 @@ } } }, - "/api/plugin/{RepoName}/updates": { + "/plugin/{RepoName}/updates": { "parameters": [ { "name": "RepoName", @@ -6655,7 +4867,7 @@ } } }, - "/api/plugin/{RepoName}/upgrade": { + "/plugin/{RepoName}/upgrade": { "parameters": [ { "name": "RepoName", @@ -6672,6 +4884,23 @@ ], "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", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When `true`, stream the upgrade output to the response instead of buffering it" + } + ], "responses": { "200": { "description": "Plugin upgraded", @@ -6690,7 +4919,7 @@ } } }, - "/api/proxies": { + "/proxies": { "delete": { "tags": [ "proxies" @@ -6806,7 +5035,7 @@ } } }, - "/api/proxies/{ProxyIp}": { + "/proxies/{ProxyIp}": { "parameters": [ { "name": "ProxyIp", @@ -6863,7 +5092,7 @@ } } }, - "/api/proxy/{Ip}/{urlPart}": { + "/proxy/{Ip}/{urlPart}": { "parameters": [ { "name": "Ip", @@ -6918,25 +5147,13 @@ } } }, - "/api/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": { @@ -6944,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." - } - } - } - }, - "/api/remoteAction": { - "get": { - "tags": [ - "remoteAction" - ], - "summary": "Proxy a command to remote FPP", - "description": "Proxies a named action to a remote FPP instance by IP address. Supported actions: `listUpgrades`, `reboot`, `restartFppd`, `upgradeOS`.", "responses": { "400": { "description": "Invalid action", @@ -6983,7 +5184,7 @@ } } }, - "/api/remotes": { + "/remotes": { "get": { "tags": [ "remotes" @@ -7008,7 +5209,7 @@ } } }, - "/api/schedule": { + "/schedule": { "get": { "tags": [ "schedule" @@ -7107,7 +5308,7 @@ } } }, - "/api/schedule/reload": { + "/schedule/reload": { "post": { "tags": [ "schedule" @@ -7138,7 +5339,7 @@ } } }, - "/api/scripts": { + "/scripts": { "get": { "tags": [ "scripts" @@ -7163,7 +5364,7 @@ } } }, - "/api/scripts/installRemote/{category}/{filename}": { + "/scripts/installRemote/{category}/{filename}": { "parameters": [ { "name": "category", @@ -7188,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", @@ -7205,7 +5412,7 @@ } } }, - "/api/scripts/viewRemote/{category}/{filename}": { + "/scripts/viewRemote/{category}/{filename}": { "parameters": [ { "name": "category", @@ -7245,7 +5452,7 @@ } } }, - "/api/scripts/{scriptName}": { + "/scripts/{scriptName}": { "parameters": [ { "name": "scriptName", @@ -7301,7 +5508,7 @@ } } }, - "/api/scripts/{scriptName}/run": { + "/scripts/{scriptName}/run": { "parameters": [ { "name": "scriptName", @@ -7318,6 +5525,12 @@ ], "summary": "Run script", "description": "Runs a locally installed script.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { "description": "Script output", @@ -7333,7 +5546,7 @@ } } }, - "/api/sequence": { + "/sequence": { "get": { "tags": [ "sequence" @@ -7359,7 +5572,7 @@ } } }, - "/api/sequence/current/step": { + "/sequence/current/step": { "get": { "tags": [ "sequence" @@ -7370,6 +5583,10 @@ { "name": "FPP REQUIRED", "color": "#c62828" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -7389,7 +5606,7 @@ } } }, - "/api/sequence/current/stop": { + "/sequence/current/stop": { "get": { "tags": [ "sequence" @@ -7404,6 +5621,10 @@ { "name": "DEVELOPER ONLY", "color": "#546e7a" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -7423,7 +5644,7 @@ } } }, - "/api/sequence/current/togglePause": { + "/sequence/current/togglePause": { "get": { "tags": [ "sequence" @@ -7438,6 +5659,10 @@ { "name": "DEVELOPER ONLY", "color": "#546e7a" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -7457,7 +5682,7 @@ } } }, - "/api/sequence/{SequenceName}": { + "/sequence/{SequenceName}": { "parameters": [ { "name": "SequenceName", @@ -7556,7 +5781,7 @@ } } }, - "/api/sequence/{SequenceName}/meta": { + "/sequence/{SequenceName}/meta": { "parameters": [ { "name": "SequenceName", @@ -7607,7 +5832,7 @@ } } }, - "/api/sequence/{SequenceName}/start/{startSecond}": { + "/sequence/{SequenceName}/start/{startSecond}": { "parameters": [ { "name": "SequenceName", @@ -7640,6 +5865,10 @@ { "name": "DEVELOPER ONLY", "color": "#546e7a" + }, + { + "name": "DEPRECATED", + "color": "#b25e00" } ], "responses": { @@ -7661,7 +5890,7 @@ } } }, - "/api/settings": { + "/settings": { "get": { "tags": [ "settings" @@ -7702,31 +5931,7 @@ } } }, - "/api/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" - } - } - } - } - } - } - }, - "/api/settings/{SettingName}": { + "/settings/{SettingName}": { "parameters": [ { "name": "SettingName", @@ -7804,7 +6009,7 @@ } } }, - "/api/settings/{SettingName}/jsonValueUpdate": { + "/settings/{SettingName}/jsonValueUpdate": { "parameters": [ { "name": "SettingName", @@ -7848,7 +6053,7 @@ } } }, - "/api/statistics/usage": { + "/statistics/usage": { "delete": { "tags": [ "statistics" @@ -7960,13 +6165,30 @@ } } }, - "/api/system/fppd/restart": { + "/system/fppd/restart": { "get": { "tags": [ "system" ], "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", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "When `1`, send a reload signal to a running fppd instead of a full stop/start" + } + ], "responses": { "200": { "description": "fppd restarted", @@ -7984,7 +6206,7 @@ } } }, - "/api/system/fppd/skipBootDelay": { + "/system/fppd/skipBootDelay": { "post": { "tags": [ "system" @@ -8009,13 +6231,19 @@ } } }, - "/api/system/fppd/start": { + "/system/fppd/start": { "get": { "tags": [ "system" ], "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", @@ -8033,13 +6261,19 @@ } } }, - "/api/system/fppd/stop": { + "/system/fppd/stop": { "get": { "tags": [ "system" ], "summary": "Stop fppd", "description": "Stops the `fppd` process if it is running.", + "x-badges": [ + { + "name": "DEPRECATED", + "color": "#b25e00" + } + ], "responses": { "200": { "description": "fppd stopped", @@ -8057,7 +6291,7 @@ } } }, - "/api/system/info": { + "/system/info": { "get": { "tags": [ "system" @@ -8106,7 +6340,7 @@ } } }, - "/api/system/packages": { + "/system/packages": { "get": { "tags": [ "system" @@ -8132,7 +6366,7 @@ } } }, - "/api/system/packages/info/{packageName}": { + "/system/packages/info/{packageName}": { "parameters": [ { "name": "packageName", @@ -8168,31 +6402,27 @@ } } }, - "/api/system/reboot": { + "/system/reboot": { "get": { "tags": [ "system" ], "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" } } } }, - "/api/system/releaseNotes/{version}": { + "/system/releaseNotes/{version}": { "parameters": [ { "name": "version", @@ -8230,31 +6460,27 @@ } } }, - "/api/system/shutdown": { + "/system/shutdown": { "get": { "tags": [ "system" ], "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" } } } }, - "/api/system/status": { + "/system/status": { "get": { "tags": [ "system" @@ -8291,7 +6517,7 @@ } } }, - "/api/system/updateStatus": { + "/system/updateStatus": { "get": { "tags": [ "system" @@ -8325,7 +6551,7 @@ } } }, - "/api/system/volume": { + "/system/volume": { "get": { "tags": [ "system" @@ -8386,7 +6612,7 @@ } } }, - "/api/testmode": { + "/testmode": { "get": { "tags": [ "testmode" @@ -8468,7 +6694,7 @@ } } }, - "/api/time": { + "/time": { "get": { "tags": [ "time" @@ -8491,133 +6717,6 @@ } } } - }, - "/api/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`," - } - } - } - }, - "/api/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/api.html b/www/api/v2/api.html new file mode 100644 index 000000000..2053fd98d --- /dev/null +++ b/www/api/v2/api.html @@ -0,0 +1,36 @@ + + + + + + FPP API v2 + + + + + + + diff --git a/www/api/v2/openapi.json b/www/api/v2/openapi.json new file mode 100644 index 000000000..52b8b44d3 --- /dev/null +++ b/www/api/v2/openapi.json @@ -0,0 +1,6596 @@ +{ + "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": "backups" + }, + { + "name": "cape" + }, + { + "name": "channel" + }, + { + "name": "configfile" + }, + { + "name": "dir" + }, + { + "name": "effects" + }, + { + "name": "email" + }, + { + "name": "events" + }, + { + "name": "file" + }, + { + "name": "files" + }, + { + "name": "git" + }, + { + "name": "media" + }, + { + "name": "network" + }, + { + "name": "options" + }, + { + "name": "pipewire" + }, + { + "name": "playlist" + }, + { + "name": "playlists" + }, + { + "name": "plugin" + }, + { + "name": "proxies" + }, + { + "name": "proxy" + }, + { + "name": "remoteAction" + }, + { + "name": "remotes" + }, + { + "name": "schedule" + }, + { + "name": "scripts" + }, + { + "name": "sequence" + }, + { + "name": "settings" + }, + { + "name": "statistics" + }, + { + "name": "system" + }, + { + "name": "testmode" + }, + { + "name": "time" + } + ], + "paths": { + "/backups/configuration": { + "post": { + "tags": [ + "backups" + ], + "summary": "Create JSON backup", + "description": "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.", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "The describing comment to be added to the backup" + } + } + }, + "responses": { + "200": { + "description": "Backup created successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "success": true, + "backup_file_path": "/home/fpp/media/config/backups/FPP_all-backup_v6_20230124212305.json", + "copied_to_usb": true + } + } + } + } + } + } + }, + "/backups/configuration/list": { + "get": { + "tags": [ + "backups" + ], + "summary": "Get available JSON backups", + "description": "Returns a list of JSON configuration backups stored locally, or \u2014 if `jsonConfigBackupUSBLocation` is set \u2014 a combined list from local storage and the configured USB device.", + "responses": { + "200": { + "description": "List of available JSON configuration backups", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "backup_alternative_location": false, + "backup_filedirectory": "/home/fpp/media/config/backups", + "backup_filename": "FPP_all-backup_v6_20230124212305.json", + "backup_comment": "FPP Settings - Disable Scheduler setting was set to ( 0 ).", + "backup_time": "Tue Jan 24 21:23:05 2023", + "backup_time_unix": "1674559385" + }, + { + "backup_alternative_location": true, + "backup_filedirectory": "/mnt/tmp/Automatic_Backups/config/backups", + "backup_filename": "FPP_all-backup_v6_20230124210519.json", + "backup_comment": "Schedule was modified.", + "backup_time": "Tue Jan 24 21:05:19 2023", + "backup_time_unix": "1674558319" + } + ] + } + } + } + } + } + }, + "/backups/configuration/list/{DeviceName}": { + "parameters": [ + { + "name": "DeviceName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "backups" + ], + "summary": "Get list of JSON backups on device", + "description": "Returns a list of JSON configuration files on a specified alternate storage device. Available devices can be obtained from `/backups/devices`, or the currently configured device is stored in the `jsonConfigBackupUSBLocation` setting.", + "responses": { + "200": { + "description": "List of JSON backup filenames on the device", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "FPP_all-backup_v6_20230114025351.json", + "FPP_all-backup_v6_20230114025354.json", + "FPP_all-backup_v6_20230114214459.json", + "FPP_all-backup_v6_20230114215622.json" + ] + } + } + } + } + } + }, + "/backups/configuration/restore/{Directory}/{BackupFilename}": { + "parameters": [ + { + "name": "Directory", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "BackupFilename", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "backups" + ], + "summary": "Restores JSON backup", + "description": "Restores the specified JSON backup. `Directory` is either `JsonBackups` (local) or `JsonBackupsAlternate` (configured alternate device). `GET /api/backups/configuration/list` can be used to obtain valid directory and filename combinations.", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "all" + } + } + }, + "responses": { + "200": { + "description": "Restore result", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Success": true, + "Message": { + "success": true, + "message": { + "channelInputs": { + "VALID_DATA": true, + "ATTEMPT": true, + "SUCCESS": true + } + } + } + } + } + } + } + } + } + }, + "/backups/configuration/{Directory}/{BackupFilename}": { + "parameters": [ + { + "name": "Directory", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "BackupFilename", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "tags": [ + "backups" + ], + "summary": "Delete JSON backup", + "description": "Deletes a specific JSON backup. `Directory` is either `JsonBackups` (local) or `JsonBackupsAlternate` (configured alternate device). `GET /api/backups/configuration/list` can be used to obtain valid directories and filenames.", + "responses": { + "200": { + "description": "Backup deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "file": "FPP_all-backup_v6_20230124210514.json", + "dir": "JsonBackupsAlternate" + } + } + } + } + } + }, + "get": { + "tags": [ + "backups" + ], + "summary": "Download JSON backup", + "description": "Downloads a specific JSON backup. `Directory` is either `JsonBackups` (local) or `JsonBackupsAlternate` (configured alternate device). `GET /api/backups/configuration/list` can be used to obtain valid directories and filenames.", + "responses": { + "200": { + "description": "Contents of the specified JSON Settings backup as a download.", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "key": "value" + } + } + } + }, + "404": { + "description": "Returned when the requested file cannot be located on disk.", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "File Not Found", + "file": "FPP_all-backup_v6_20230124210514.json", + "dir": "JsonBackups" + } + } + } + } + } + } + }, + "/backups/devices": { + "get": { + "tags": [ + "backups" + ], + "summary": "Get devices available for backups", + "description": "Returns a list of devices (e.g. USB drives, SSDs) attached to the system that can be used for backups.", + "responses": { + "200": { + "description": "List of available backup devices", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "name": "sda1", + "size": 7.5, + "model": "Cruzer Blade", + "vendor": "SanDisk" + } + ] + } + } + } + } + } + }, + "/backups/devices/mount/{DeviceName}/{MountLocation}": { + "parameters": [ + { + "name": "DeviceName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "MountLocation", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "backups" + ], + "summary": "Mount device", + "description": "Mounts the specified device to `/mnt/{MountLocation}` (defaults to `/mnt/api_mount`).", + "responses": { + "200": { + "description": "Device mounted successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "Device (sda1) mounted at (/mnt/api_mount)", + "MountLocation": "/mnt/api_mount" + } + } + } + } + } + } + }, + "/backups/devices/unmount/{DeviceName}/{MountLocation}": { + "parameters": [ + { + "name": "DeviceName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "MountLocation", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "backups" + ], + "summary": "Unmount device", + "description": "Unmounts the drive at `/mnt/{MountLocation}` (defaults to `/mnt/api_mount`).", + "responses": { + "200": { + "description": "Device unmounted successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "Device (sda1) unmounted from (/mnt/api_mount)", + "MountLocation": "/mnt/api_mount" + } + } + } + } + } + } + }, + "/backups/list": { + "get": { + "tags": [ + "backups" + ], + "summary": "Get available backups", + "description": "Returns a list of full system backup files stored in the local `backups/` directory.", + "responses": { + "200": { + "description": "List of backup directory names", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "/", + "FPPDevP4", + "FPPDevP4_2026_05_02" + ] + } + } + } + } + } + }, + "/backups/list/{DeviceName}": { + "parameters": [ + { + "name": "DeviceName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "backups" + ], + "summary": "Get list of backups on device", + "description": "Returns a list of full system backup files stored on the specified device (e.g. a USB drive).", + "responses": { + "200": { + "description": "List of backup directory names on the device", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "/", + "FPPDevP4", + "FPPDevP4_2026_05_02" + ] + } + } + } + } + } + }, + "/cape": { + "get": { + "tags": [ + "cape" + ], + "summary": "Get cape information", + "description": "Returns the cape information for the currently detected hardware cape (from `cape-info` settings).", + "responses": { + "200": { + "description": "Cape hardware information", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "id": "K16A-Bv1", + "name": "K16A-B", + "description": "K16A-B is a cape for the BeagleBone Black...", + "version": "1.0", + "designer": "Daniel Kulp", + "vendor": { + "name": "Kulp Lights", + "url": "https://kulplights.com/", + "email": "sales@kulplights.com", + "image": "https://kulplights.com/images/kulplights_small.png" + }, + "provides": [ + "strings" + ], + "serialNumber": "XXXXXXXXXXXXXX", + "validEepromLocation": true, + "verifiedKeyId": "dk", + "eepromLocation": "/sys/bus/i2c/devices/2-0050/eeprom", + "modules": [ + "gpio_pcf857x", + "pcm5102a", + "lm75" + ], + "i2cDevices": [ + "pca9675 0x20", + "pcf8523 0x68", + "lm75 0x48" + ], + "defaultSettings": { + "LEDDisplayType": "1", + "piRTC": "4", + "showAllOptions": "0" + } + } + } + } + }, + "404": { + "description": "No cape detected", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "id": "No Cape!" + } + } + } + } + } + } + }, + "/cape/eeprom/sign/{key}/{order}": { + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "cape" + ], + "summary": "Sign EEPROM", + "description": "Signs the cape EEPROM by sending its data to the FalconPlayer.com signing API using the provided `key` and order ID.", + "responses": { + "200": { + "description": "EEPROM signed successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "EEPROM Signed." + } + } + } + } + } + } + }, + "/cape/eeprom/signingData": { + "post": { + "tags": [ + "cape" + ], + "summary": "Upload signed EEPROM", + "description": "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.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "key": "ABCD-1234", + "orderID": "42", + "serial": "1000000012345678", + "eeprom": "" + } + } + } + }, + "responses": { + "200": { + "description": "Signed EEPROM written successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "EEPROM Signed." + } + } + } + } + } + } + }, + "/cape/eeprom/signingData/{key}/{order}": { + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "cape" + ], + "summary": "Get signing data as text", + "description": "Returns the cape EEPROM signing data payload for use with an external signing service.", + "responses": { + "200": { + "description": "EEPROM signing data payload", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "key": "ABCD-1234", + "orderID": "42", + "serial": "1000000012345678", + "eeprom": "" + } + } + } + } + } + } + }, + "/cape/eeprom/signingFile/{key}/{order}": { + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "cape" + ], + "summary": "Get signing data as binary", + "description": "Downloads the cape EEPROM signing data as a binary file attachment.", + "responses": { + "200": { + "description": "EEPROM signing data as binary file attachment", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/cape/eeprom/voucher": { + "post": { + "tags": [ + "cape" + ], + "summary": "Redeem signing voucher", + "description": "Redeems a voucher code against the FalconPlayer.com signing API to obtain a signing `key` and order ID.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "voucher": "XXXX-XXXX-XXXX-XXXX", + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com", + "password": "secret" + } + } + } + }, + "responses": { + "200": { + "description": "Voucher redeemed successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "", + "key": "ABCD-1234", + "order": "42" + } + } + } + } + } + } + }, + "/cape/options": { + "get": { + "tags": [ + "cape" + ], + "summary": "Get cape options", + "description": "Returns a list of available cape EEPROM options for the current platform.", + "responses": { + "200": { + "description": "Available cape EEPROM options", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "--None--", + "F16-B", + "F32-B", + "F4-B", + "F8-B", + "F8-Bv2", + "RGB-123" + ] + } + } + } + } + } + }, + "/cape/panel": { + "get": { + "tags": [ + "cape" + ], + "summary": "Get all cape panels", + "description": "Returns a list of available LED panel cape configuration `key` values.", + "responses": { + "200": { + "description": "Available LED panel cape configuration keys", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [] + } + } + } + } + } + }, + "/cape/panel/{key}": { + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "cape" + ], + "summary": "Get cape panel", + "description": "Returns the LED panel cape configuration JSON for the specified `key`.", + "responses": { + "200": { + "description": "LED panel cape configuration", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": {} + } + } + } + } + } + }, + "/cape/strings": { + "get": { + "tags": [ + "cape" + ], + "summary": "Get all cape strings", + "description": "Returns a list of available string cape configuration `key` values.", + "responses": { + "200": { + "description": "Available string cape configuration keys", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "F16v3-strings", + "F8v2-strings" + ] + } + } + } + } + } + }, + "/cape/strings/{key}": { + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "cape" + ], + "summary": "Get cape string", + "description": "Returns the string cape configuration JSON for the specified `key`.", + "responses": { + "200": { + "description": "String cape configuration", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "K16A-B", + "longName": "K16A-B", + "pinoutVersion": "1.x", + "numSerial": 0, + "supportsSmartReceivers": true, + "outputs": [ + { + "pin": "P8-45" + } + ], + "groups": [ + { + "start": 1, + "count": 16 + } + ], + "serial": [] + } + } + } + }, + "404": { + "description": "Key not found", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "Not Found!" + ] + } + } + } + } + } + }, + "/channel/input/stats": { + "delete": { + "tags": [ + "channel" + ], + "summary": "Reset E1.31 stats", + "description": "Resets the E1.31/DDP channel input statistics counters.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Statistics reset", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + }, + "get": { + "tags": [ + "channel" + ], + "summary": "Get E1.31 stats", + "description": "Returns the E1.31 or DDP statistics for inbound packets. Returns a meaningful error if the connection to `fppd` fails.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "E1.31/DDP channel input statistics", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "universes": [ + { + "bytesReceived": "325632", + "errors": "1", + "id": 1, + "packetsReceived": "636", + "startChannel": 1 + } + ] + } + } + } + } + } + } + }, + "/channel/output/processors": { + "get": { + "tags": [ + "channel" + ], + "summary": "Get output processors", + "description": "Returns the current configuration of any output processors.", + "responses": { + "200": { + "description": "Current output processor configuration", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "outputProcessors": [ + { + "type": "Brightness", + "active": 0, + "description": "", + "start": 1, + "count": 10, + "brightness": 50, + "gamma": 1 + } + ], + "status": "OK" + } + } + } + } + } + }, + "post": { + "tags": [ + "channel" + ], + "summary": "Set output processors", + "description": "Overwrites the output processor settings file with a new configuration and returns the saved configuration.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "outputProcessors": [ + { + "type": "Brightness", + "active": 0, + "description": "", + "start": 1, + "count": 10, + "brightness": 50, + "gamma": 1 + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Current output processor configuration", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "outputProcessors": [ + { + "type": "Brightness", + "active": 0, + "description": "", + "start": 1, + "count": 10, + "brightness": 50, + "gamma": 1 + } + ], + "status": "OK" + } + } + } + } + } + } + }, + "/channel/output/{file}": { + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "channel" + ], + "summary": "Get channel output", + "description": "Returns the current configuration of the specified output file in JSON format. Common values of `{file}` include `universeOutputs`, `universeInputs`, `co-other`, `dmxInputs`, `co-pwm`, and `co-bbbStrings`. Supports an optional `?ip=` query parameter to fetch from a remote FPP instance.", + "responses": { + "200": { + "description": "Channel output configuration file contents", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": {} + } + } + }, + "404": { + "description": "File not found", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "ERROR: File not found" + } + } + } + } + } + }, + "post": { + "tags": [ + "channel" + ], + "summary": "Set channel output", + "description": "Overwrites the specified output configuration file with the `POST` body and returns the saved configuration.", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Format varies based on file" + } + } + }, + "responses": { + "200": { + "description": "Saved configuration echoed back", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": {} + } + } + } + } + } + }, + "/configfile": { + "get": { + "tags": [ + "configfile" + ], + "summary": "Get directory list", + "description": "Returns a list of config files in `/home/fpp/media/config` or an optional subdirectory.", + "responses": { + "200": { + "description": "Directory listing", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Path": "", + "ConfigFiles": [ + "File1", + "File2", + "File3" + ] + } + } + } + } + } + } + }, + "/configfile/**": { + "delete": { + "tags": [ + "configfile" + ], + "summary": "Delete configuration file", + "description": "Deletes a config file from `/home/fpp/media/config`.", + "responses": { + "200": { + "description": "File deleted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + }, + "get": { + "tags": [ + "configfile" + ], + "summary": "Get file or directory list", + "description": "Returns the contents of a specific config file, or a directory listing if the path resolves to a directory.", + "responses": { + "200": { + "description": "Raw config file contents", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "(Raw config file contents)" + } + } + } + } + }, + "post": { + "tags": [ + "configfile" + ], + "summary": "Upload configuration file", + "description": "Uploads or overwrites a config file in `/home/fpp/media/config`, creating any necessary subdirectories. Accepts a multipart file upload or raw `POST` body.", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "(Raw config file contents)" + } + } + }, + "responses": { + "200": { + "description": "File uploaded", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/dir/{DirName}/{SubDir}": { + "parameters": [ + { + "name": "DirName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "SubDir", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "tags": [ + "dir" + ], + "summary": "Delete empty subdirectory", + "description": "Deletes an empty subdirectory from the specified media directory.", + "responses": { + "200": { + "description": "Subdirectory deleted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "subdir": "mySubDir", + "dir": "sequences" + } + } + } + } + } + }, + "post": { + "tags": [ + "dir" + ], + "summary": "Create subdirectory", + "description": "Creates a subdirectory inside the specified media directory.", + "responses": { + "200": { + "description": "Subdirectory created", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "subdir": "mySubDir", + "dir": "sequences" + } + } + } + } + } + } + }, + "/effects": { + "get": { + "tags": [ + "effects" + ], + "summary": "Get effects", + "description": "Returns a list of effect (`*.eseq`) files available in the effects directory.", + "responses": { + "200": { + "description": "List of effect filenames", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "rainbow", + "twinkle" + ] + } + } + } + } + } + }, + "/effects/ALL": { + "get": { + "tags": [ + "effects" + ], + "summary": "Get all effects", + "description": "Returns a combined list of all effect (`*.eseq`) files from both the effects directory and the sequences directory.", + "responses": { + "200": { + "description": "Combined list of effect and sequence filenames", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "rainbow", + "twinkle", + "MySequence" + ] + } + } + } + } + } + }, + "/email/configure": { + "post": { + "tags": [ + "email" + ], + "summary": "Set email options", + "description": "Configures outbound email using the existing settings.", + "responses": { + "200": { + "description": "Email configured", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/email/test": { + "post": { + "tags": [ + "email" + ], + "summary": "Send test email", + "description": "Sends a test email using the existing settings.", + "responses": { + "200": { + "description": "Test email sent", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/events": { + "get": { + "tags": [ + "events" + ], + "summary": "Get all event files", + "description": "Returns a map of all event (`*.fevt`) files, keyed by event ID (filename without extension).", + "responses": { + "200": { + "description": "Map of all event files keyed by event ID", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "1_1": { + "name": "My Event", + "effect": "rainbow", + "startChannel": 1 + } + } + } + } + } + } + } + }, + "/events/{eventId}": { + "parameters": [ + { + "name": "eventId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "events" + ], + "summary": "Get event file", + "description": "Returns the contents of a specific event file. If `{eventId}` is `ids`, returns a map of event IDs to display names.", + "responses": { + "200": { + "description": "Event file contents", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "My Event", + "effect": "rainbow", + "startChannel": 1 + } + } + } + } + } + } + }, + "/events/{eventId}/trigger": { + "parameters": [ + { + "name": "eventId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "events" + ], + "summary": "Trigger event", + "description": "Triggers the specified event by sending a `Trigger Event` command to `fppd`.", + "responses": { + "200": { + "description": "Event triggered", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/file/info/{plugin}/{ext}/**": { + "parameters": [ + { + "name": "plugin", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "ext", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "file" + ], + "summary": "Get plugin file info", + "description": "Returns plugin-specific file info for the specified file path. The plugin name, extension category, and file path are read from route parameters. The metadata command is defined in the plugin's `pluginInfo.json`.", + "responses": { + "200": { + "description": "Plugin-specific file information", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": {} + } + } + } + } + } + }, + "/file/move/{fileName}": { + "parameters": [ + { + "name": "fileName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "file" + ], + "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.", + "responses": { + "200": { + "description": "File moved to media directory", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/file/onUpload/{ext}/**": { + "parameters": [ + { + "name": "ext", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "file" + ], + "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.", + "responses": { + "200": { + "description": "Plugin notified of upload", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/file/{DirName}": { + "parameters": [ + { + "name": "DirName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "file" + ], + "summary": "Upload file", + "description": "Handles chunked file uploads via `PATCH` (TUS-style). A `POST` to the same route initiates the session and returns a unique upload ID. Each `PATCH` request delivers a chunk identified by `Upload-Name`, `Upload-Offset`, and `Upload-Length` headers; when all chunks arrive, the file is assembled.", + "responses": { + "200": { + "description": "Upload chunk received", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "file": "block_driveways.xbkp", + "dir": "uploads", + "size": 1048576 + } + } + } + } + } + } + }, + "/file/{DirName}/**": { + "parameters": [ + { + "name": "DirName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "tags": [ + "file" + ], + "summary": "Delete file or directory", + "description": "Deletes the specified file or directory from a media directory. Validates the resolved path against the allowed base directory to prevent path traversal.", + "responses": { + "200": { + "description": "File or directory deleted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "file": "block_driveways.xbkp", + "dir": "uploads" + } + } + } + } + } + }, + "get": { + "tags": [ + "file" + ], + "summary": "Get file contents", + "description": "Downloads the specified file from a media directory.", + "parameters": [ + { + "name": "tail", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Return the last N lines instead of the whole file" + }, + { + "name": "play", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When `1`, set a playback-oriented content type instead of a forced attachment" + }, + { + "name": "attach", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When `1`, force attachment download for images" + } + ], + "responses": { + "200": { + "description": "File contents; content type varies by file extension and query params", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "File not found", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "File does not exist." + } + } + } + } + } + }, + "/file/{DirName}/copy/{source}/{dest}": { + "parameters": [ + { + "name": "DirName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "source", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "dest", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "file" + ], + "summary": "Copy file", + "description": "Copies the specified file from `:source` to `:dest` within the given directory.", + "responses": { + "200": { + "description": "File copied successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "success", + "original": "test.py", + "new": "test2.py" + } + } + } + } + } + } + }, + "/file/{DirName}/rename/{source}/{dest}": { + "parameters": [ + { + "name": "DirName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "source", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "dest", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "file" + ], + "summary": "Rename file", + "description": "Renames the specified file from `:source` to `:dest` within the given directory.", + "responses": { + "200": { + "description": "File renamed successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "success", + "original": "test.py", + "new": "test2.py" + } + } + } + } + } + } + }, + "/file/{DirName}/tailfollow/*": { + "parameters": [ + { + "name": "DirName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "file" + ], + "summary": "Stream tail of file", + "description": "Streams the tail of a log file using Server-Sent Events (SSE). Only works for files in the `logs` directory.", + "parameters": [ + { + "name": "lines", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Number of existing lines to seed into the stream, from 1 to 500, default 50" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + }, + "example": "[12-May-2026 01:24:10] NOTICE: fpm is running, pid 80\n[12-May-2026 01:24:10] NOTICE: ready to handle connections" + } + } + }, + "403": { + "description": "Forbidden filename", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Invalid file path." + } + } + }, + "404": { + "description": "File not found", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "File not found: " + } + } + } + } + } + }, + "/file/{DirName}/{Name}": { + "parameters": [ + { + "name": "DirName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "file" + ], + "summary": "Upload file to directory", + "description": "Uploads a file to the specified media directory.", + "parameters": [ + { + "name": "bs", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Block size used for fragmented uploads" + }, + { + "name": "sb", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Starting block index used for fragmented uploads" + } + ], + "responses": { + "200": { + "description": "File uploaded successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "file": "beepbeep.fseq", + "dir": "sequences" + } + } + } + } + } + } + }, + "/files/zip/{DirNames}": { + "parameters": [ + { + "name": "DirNames", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "files" + ], + "summary": "Get zip file of directories", + "description": "Downloads all files in the specified directory (or comma-separated list of directories) as a zip archive. `logs` and `config` are handled specially to include system log and config files.", + "responses": { + "200": { + "description": "Binary file stream of the compressed system archive.", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/files/{DirName}": { + "parameters": [ + { + "name": "DirName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "files" + ], + "summary": "Get all files", + "description": "Returns a list of files in the specified media directory.", + "parameters": [ + { + "name": "nameOnly", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When `1`, return a flat array of filenames instead of the default object envelope" + } + ], + "responses": { + "200": { + "description": "Listing of files", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "ok", + "files": [ + { + "name": "Christmas Every Day.mp3", + "mtime": "09/23/20 07:47 PM", + "sizeBytes": 7929000, + "sizeHuman": "7.56MB", + "playtimeSeconds": "03m:46s" + } + ] + } + } + } + } + } + } + }, + "/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/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": { + "post": { + "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'." + } + } + } + } + } + } + }, + "/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" + } + ] + } + } + } + } + } + } + }, + "/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" + } + } + ], + "post": { + "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.", + "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" + } + } + } + }, + "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", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "output": [] + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + }, + "post": { + "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" + } + ] + } + } + } + } + } + } + }, + "/network/wifi/strength": { + "get": { + "tags": [ + "network" + ], + "summary": "Get all wifi signal strenths", + "description": "Returns signal strength information for wireless network interfaces.", + "responses": { + "200": { + "description": "Wi-Fi signal strength per interface", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "interface": "wlan0", + "link": 45, + "level": -65, + "noise": -256 + } + ] + } + } + } + } + } + }, + "/options/{SettingName}": { + "parameters": [ + { + "name": "SettingName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "options" + ], + "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", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Dummy": "0" + } + } + } + } + } + } + }, + "/pipewire/control/groups": { + "get": { + "tags": [ + "pipewire" + ], + "summary": "List output groups with live state", + "description": "Returns every configured audio output group together with its member sound cards. Volume/mute for groups and member cards are read live from PipeWire (`volumeSource: \"live\"` when the sink is running, else the saved config value with `volumeSource: \"config\"`).", + "responses": { + "200": { + "description": "Output groups with live runtime state", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "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" + } + ] + } + ] + } + } + } + } + } + } + }, + "/pipewire/control/groups/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "pipewire" + ], + "summary": "Get one output group with live state", + "description": "Returns a single audio output group (by numeric group id) with its member sound cards and live runtime volume/mute.", + "responses": { + "200": { + "description": "Output group with live runtime state", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "group": { + "id": 1, + "name": "Front", + "enabled": true, + "nodeName": "fpp_group_front", + "liveVolume": 80, + "liveMute": false, + "running": true, + "members": [] + } + } + } + } + }, + "404": { + "description": "Output group not found" + } + } + } + }, + "/pipewire/control/groups/{id}/members/{cardId}/mute": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "cardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "pipewire" + ], + "summary": "Mute or unmute a member sound card", + "description": "Sets the mute state of an individual sound card within an output group, addressed by its ALSA card id. Provide `mute` (bool) or `toggle: true`. Applied live and persisted.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "toggle": true + } + } + } + }, + "responses": { + "200": { + "description": "Member mute state applied and persisted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "groupId": 1, + "cardId": "S3", + "nodeName": "fpp_fx_g1_s3", + "mute": true, + "applied": true + } + } + } + }, + "400": { + "description": "Provide 'mute' (bool) or 'toggle': true" + }, + "404": { + "description": "Card not found in group" + }, + "409": { + "description": "PipeWire backend not active" + } + } + } + }, + "/pipewire/control/groups/{id}/members/{cardId}/volume": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "cardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "pipewire" + ], + "summary": "Set member sound-card volume", + "description": "Sets the volume (0-150%) of an individual sound card within an output group, addressed by its stable ALSA card id (e.g. `S3`). Applied live to the member filter-chain sink and persisted.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "volume": 75 + } + } + } + }, + "responses": { + "200": { + "description": "Member volume applied and persisted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "groupId": 1, + "cardId": "S3", + "nodeName": "fpp_fx_g1_s3", + "volume": 75, + "applied": true, + "message": "Volume set to 75%" + } + } + } + }, + "400": { + "description": "Missing 'volume'" + }, + "404": { + "description": "Card not found in group" + }, + "409": { + "description": "PipeWire backend not active" + } + } + } + }, + "/pipewire/control/groups/{id}/mute": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "pipewire" + ], + "summary": "Mute or unmute an output group", + "description": "Sets the mute state of an output group's combined sink. Provide an explicit `mute` boolean, or `toggle: true` to flip the current state. Applied live and persisted.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "mute": true + } + } + } + }, + "responses": { + "200": { + "description": "Mute state applied and persisted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "groupId": 1, + "nodeName": "fpp_group_front", + "mute": true, + "applied": true + } + } + } + }, + "400": { + "description": "Provide 'mute' (bool) or 'toggle': true" + }, + "404": { + "description": "Output group not found" + }, + "409": { + "description": "PipeWire backend not active" + } + } + } + }, + "/pipewire/control/groups/{id}/volume": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "pipewire" + ], + "summary": "Set output group volume", + "description": "Sets the master volume (0-150%) of an audio output group's combined PipeWire sink. The value is applied live and persisted to the group config so it survives a reboot.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "volume": 80 + } + } + } + }, + "responses": { + "200": { + "description": "Volume applied and persisted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "groupId": 1, + "nodeName": "fpp_group_front", + "volume": 80, + "applied": true, + "message": "Volume set to 80%" + } + } + } + }, + "400": { + "description": "Missing 'volume'" + }, + "404": { + "description": "Output group not found" + }, + "409": { + "description": "PipeWire backend not active" + } + } + } + }, + "/pipewire/control/input-groups": { + "get": { + "tags": [ + "pipewire" + ], + "summary": "List input groups (mix buses)", + "description": "Returns every configured input group (mix bus) with its members and the output groups it routes to. Member volume/mute reflect the saved config (`volumeSource: \"config\"`) plus a live `running` flag indicating whether the loopback node is currently active.", + "responses": { + "200": { + "description": "Input groups with member state", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "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" + } + ] + } + ] + } + } + } + } + } + } + }, + "/pipewire/control/input-groups/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "pipewire" + ], + "summary": "Get one input group (mix bus)", + "description": "Returns a single input group by numeric id with its members and routing targets.", + "responses": { + "200": { + "description": "Input group with member state", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "inputGroup": { + "id": 1, + "name": "Main Mix", + "enabled": true, + "channels": 2, + "outputs": [ + 1 + ], + "members": [] + } + } + } + } + }, + "404": { + "description": "Input group not found" + } + } + } + }, + "/pipewire/control/input-groups/{id}/members/{memberIndex}/mute": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "memberIndex", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "pipewire" + ], + "summary": "Mute or unmute an input-group member", + "description": "Sets the mute state of a member within an input group, addressed by zero-based member index. Loopback nodes have no mute property, so mute is implemented by driving channelmix volume to 0 and unmute restores the saved member volume. Provide `mute` (bool) or `toggle: true`.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "mute": true + } + } + } + }, + "responses": { + "200": { + "description": "Member mute state applied and persisted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "inputGroupId": 1, + "memberIndex": 0, + "mute": true, + "applied": true + } + } + } + }, + "400": { + "description": "Provide 'mute' (bool) or 'toggle': true" + }, + "404": { + "description": "Member not found in input group" + }, + "409": { + "description": "PipeWire backend not active" + } + } + } + }, + "/pipewire/control/input-groups/{id}/members/{memberIndex}/volume": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "memberIndex", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "pipewire" + ], + "summary": "Set input-group member volume", + "description": "Sets the volume (0-100%) of a member within an input group (mix bus), addressed by its zero-based member index. Applied live to the member loopback via channelmix and persisted. Note: the primary fppd stream's volume is governed by fppd itself, not a loopback.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "volume": 60 + } + } + } + }, + "responses": { + "200": { + "description": "Member volume applied and persisted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "inputGroupId": 1, + "memberIndex": 0, + "volume": 60, + "applied": true, + "message": "Volume set to 60%" + } + } + } + }, + "400": { + "description": "Missing 'volume'" + }, + "404": { + "description": "Member not found in input group" + }, + "409": { + "description": "PipeWire backend not active" + } + } + } + }, + "/pipewire/control/routing": { + "get": { + "tags": [ + "pipewire" + ], + "summary": "Get the routing matrix", + "description": "Returns the full input-group to output-group routing matrix. For every input group, each possible output-group path is listed with its connected state, per-path volume and mute. Values reflect the saved config (`volumeSource: \"config\"`).", + "responses": { + "200": { + "description": "Routing matrix", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "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 + } + ] + } + ] + } + } + } + } + } + } + }, + "/pipewire/control/routing/{inputGroupId}/{outputGroupId}/mute": { + "parameters": [ + { + "name": "inputGroupId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "outputGroupId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "pipewire" + ], + "summary": "Mute or unmute a routing path", + "description": "Sets the mute state of a single input-group to output-group routing path. Mute drives the routing channelmix volume to 0; unmute restores the saved per-path volume. Provide `mute` (bool) or `toggle: true`. Persisted to the input-group routing config.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "toggle": true + } + } + } + }, + "responses": { + "200": { + "description": "Route mute state applied and persisted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "inputGroupId": 1, + "outputGroupId": 2, + "mute": true, + "applied": true + } + } + } + }, + "400": { + "description": "Provide 'mute' (bool) or 'toggle': true" + }, + "409": { + "description": "PipeWire backend not active" + } + } + } + }, + "/pipewire/control/routing/{inputGroupId}/{outputGroupId}/volume": { + "parameters": [ + { + "name": "inputGroupId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "outputGroupId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "pipewire" + ], + "summary": "Set routing-path volume", + "description": "Sets the volume (0-100%) of a single input-group to output-group routing path. Applied live to the routing combine-stream and persisted to the input-group routing config.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "volume": 50 + } + } + } + }, + "responses": { + "200": { + "description": "Route volume applied and persisted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "inputGroupId": 1, + "outputGroupId": 2, + "volume": 50, + "applied": true, + "message": "Route volume set to 50%" + } + } + } + }, + "400": { + "description": "Missing 'volume'" + }, + "409": { + "description": "PipeWire backend not active" + } + } + } + }, + "/pipewire/control/status": { + "get": { + "tags": [ + "pipewire" + ], + "summary": "PipeWire control status", + "description": "Reports whether the PipeWire backend is active, the systemd service health of the PipeWire/WirePlumber/pulse units, and configured group counts. Use this to discover capability before issuing control calls.", + "responses": { + "200": { + "description": "PipeWire backend status", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "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 + } + } + } + } + } + } + }, + "/pipewire/control/streams": { + "get": { + "tags": [ + "pipewire" + ], + "summary": "List fppd stream slots", + "description": "Returns the status of all 5 fppd media stream slots (idle/playing). Slot 1 additionally reports the currently playing media filename and timing from fppd.", + "responses": { + "200": { + "description": "Stream slot status", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "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": "" + } + ] + } + } + } + } + } + } + }, + "/pipewire/control/streams/{slot}/volume": { + "parameters": [ + { + "name": "slot", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "pipewire" + ], + "summary": "Set fppd stream slot volume", + "description": "Sets the volume (0-100%) of an fppd media stream slot (1-5). Slot 1 uses fppd's built-in volume control; slots 2-5 are set live via PipeWire channelmix on the stream node. The response `control` field indicates which path was used.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "volume": 90 + } + } + } + }, + "responses": { + "200": { + "description": "Stream volume applied", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "slot": 1, + "volume": 90, + "applied": true, + "control": "fppd" + } + } + } + }, + "400": { + "description": "Missing 'volume'" + }, + "409": { + "description": "PipeWire backend not active" + } + } + } + }, + "/playlist/{PlaylistName}": { + "parameters": [ + { + "name": "PlaylistName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "tags": [ + "playlist" + ], + "summary": "Delete playlist", + "description": "Delete the playlist named {PlaylistName}.", + "responses": { + "200": { + "description": "Playlist deleted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + }, + "get": { + "tags": [ + "playlist" + ], + "summary": "Get a playlist", + "description": "Get the playlist named `{PlaylistName}` in FPP JSON format. If `?mergeSubs=1` is specified, sub-playlists are recursively merged into the parent sections.", + "parameters": [ + { + "name": "mergeSubs", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Merge sub-playlsits recursively" + } + ], + "responses": { + "200": { + "description": "Playlist details", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "UploadTest", + "globalPauseBetweenSequencesMS": 5000, + "mainPlaylist": [ + { + "type": "pause", + "enabled": 1, + "playOnce": 0, + "duration": 8 + } + ], + "playlistInfo": { + "total_duration": 8, + "total_items": 1 + } + } + } + } + } + } + }, + "post": { + "tags": [ + "playlist" + ], + "summary": "Upsert playlist", + "description": "Update or Insert (upsert) the playlist named {PlaylistName}.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "UploadTest", + "globalPauseBetweenSequencesMS": 5000, + "mainPlaylist": [ + { + "type": "pause", + "enabled": 1, + "playOnce": 0, + "duration": 8 + } + ], + "playlistInfo": { + "total_duration": 8, + "total_items": 1 + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated playlist", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "UploadTest", + "globalPauseBetweenSequencesMS": 5000, + "mainPlaylist": [ + { + "type": "pause", + "enabled": 1, + "playOnce": 0, + "duration": 8 + } + ], + "playlistInfo": { + "total_duration": 8, + "total_items": 1 + } + } + } + } + } + } + } + }, + "/playlist/{PlaylistName}/start": { + "parameters": [ + { + "name": "PlaylistName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "playlist" + ], + "summary": "Start playlist", + "description": "Start the playlist named `{PlaylistName}`. The optional query parameter `scheduleProtected` (`true`/`false`) prevents the scheduler from stopping this playlist.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Playlist started", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/playlist/{PlaylistName}/start/{Repeat}": { + "parameters": [ + { + "name": "PlaylistName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Repeat", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "playlist" + ], + "summary": "Start playlist on repeat", + "description": "Start the playlist named `{PlaylistName}` with repeat mode. The optional query parameter `scheduleProtected` (`true`/`false`) prevents the scheduler from stopping this playlist.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "parameters": [ + { + "name": "scheduleProtected", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Prevent schedule from stopping this playlist" + } + ], + "responses": { + "200": { + "description": "Playlist started", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/playlist/{PlaylistName}/start/{Repeat}/{ScheduleProtected}": { + "parameters": [ + { + "name": "PlaylistName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Repeat", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "ScheduleProtected", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "playlist" + ], + "summary": "Start playlist on repeat (alt)", + "description": "Start the playlist named `{PlaylistName}` with repeat mode and schedule protection. When `{ScheduleProtected}` is `true`, the scheduler cannot stop this playlist.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Playlist started", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/playlist/{PlaylistName}/{SectionName}/item": { + "parameters": [ + { + "name": "PlaylistName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "SectionName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "playlist" + ], + "summary": "Insert section into playlist", + "description": "Insert an item into the `{SectionName}` section of playlist `{PlaylistName}`.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "type": "pause", + "enabled": 1, + "playOnce": 0, + "duration": 8 + } + } + } + }, + "responses": { + "200": { + "description": "Item inserted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/playlists": { + "get": { + "tags": [ + "playlists" + ], + "summary": "Get all playlists", + "description": "Get list of playlist names.", + "responses": { + "200": { + "description": "List of playlist names", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "Playlist_1", + "Playlist_2", + "Playlist_3" + ] + } + } + } + } + }, + "post": { + "tags": [ + "playlists" + ], + "summary": "Create playlist", + "description": "Insert a new playlist.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "UploadTest", + "globalPauseBetweenSequencesMS": 5000, + "mainPlaylist": [ + { + "type": "pause", + "enabled": 1, + "playOnce": 0, + "duration": 8 + } + ], + "playlistInfo": { + "total_duration": 8, + "total_items": 1 + } + } + } + } + }, + "responses": { + "200": { + "description": "Newly created playlist", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "UploadTest", + "globalPauseBetweenSequencesMS": 5000, + "mainPlaylist": [ + { + "type": "pause", + "enabled": 1, + "playOnce": 0, + "duration": 8 + } + ], + "playlistInfo": { + "total_duration": 8, + "total_items": 1 + } + } + } + } + } + } + } + }, + "/playlists/pause": { + "post": { + "tags": [ + "playlists" + ], + "summary": "Pause currently running playlist", + "description": "Pause the currently running playlist.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Playlist paused", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/playlists/playable": { + "get": { + "tags": [ + "playlists" + ], + "summary": "Get playable objects", + "description": "Get a combined list of playlist names and `*.fseq` sequence filenames that are playable.", + "responses": { + "200": { + "description": "Playable playlist and sequence names", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "Playlist_1", + "Playlist_2", + "MySequence.fseq" + ] + } + } + } + } + } + }, + "/playlists/resume": { + "post": { + "tags": [ + "playlists" + ], + "summary": "Resume paused playlist", + "description": "Resume a previously paused playlist.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Playlist resumed", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/playlists/stop": { + "post": { + "tags": [ + "playlists" + ], + "summary": "Stop playlist", + "description": "Immediately stop the currently running playlist.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Playlist stopped", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/playlists/stopgracefully": { + "post": { + "tags": [ + "playlists" + ], + "summary": "Gracefully stop playlist", + "description": "Gracefully stop the currently running playlist.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Graceful stop initiated", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/playlists/stopgracefullyafterloop": { + "post": { + "tags": [ + "playlists" + ], + "summary": "Gracefully stop at end of loop", + "description": "Gracefully stop the currently running playlist after completion of the current loop.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Stop after loop initiated", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/playlists/validate": { + "get": { + "tags": [ + "playlists" + ], + "summary": "Validate all playlists", + "description": "Returns a list of all playlists with any validation errors, total item counts, and total duration.", + "responses": { + "200": { + "description": "Validation results for all playlists", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "name": "Test1", + "description": "User entered playlist description", + "valid": true, + "messages": [], + "total_duration": 10, + "total_items": 3, + "version": 4, + "leadIn_items": 0, + "mainPlaylist_items": 3, + "leadOut_items": 0 + } + ] + } + } + } + } + } + }, + "/plugin": { + "get": { + "tags": [ + "plugin" + ], + "summary": "Get all plugins", + "description": "Get list of installed plugins.", + "responses": { + "200": { + "description": "List of installed plugin names", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "fpp-brightness", + "fpp-matrixtools", + "fpp-vastfmt" + ] + } + } + } + } + }, + "post": { + "tags": [ + "plugin" + ], + "summary": "Install plugin", + "description": "Install a new plugin. The request body is a `pluginInfo.json` structure with `branch` and `sha` fields added to specify which branch and commit to install.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "repoName": "fpp-matrixtools", + "name": "MatrixTools", + "author": "Chris Pinkham (CaptainMurdoch)", + "srcURL": "https://github.com/cpinkham/fpp-matrixtools.git", + "branch": "master", + "sha": "" + } + } + } + }, + "responses": { + "200": { + "description": "Plugin installed", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/plugin/fetchInfo": { + "post": { + "tags": [ + "plugin" + ], + "summary": "Get plugin info from URL", + "description": "Server-side proxy for fetching a `pluginInfo.json` from a remote URL. Used to retrieve plugin repository info without CORS issues, and to authenticate against private GitHub repositories using credentials configured on the Developer settings page.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "url": "https://example.com/pluginInfo.json", + "useCredentials": 1 + } + } + } + }, + "responses": { + "200": { + "description": "Plugin info fetched from remote URL", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": {} + } + } + } + } + } + }, + "/plugin/headerIndicators": { + "get": { + "tags": [ + "plugin" + ], + "summary": "Get header indicators", + "description": "Returns header indicator data (e.g., notification badges) from all installed plugins that define a `headerIndicators.php` file.", + "responses": { + "200": { + "description": "Plugin header indicator data", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "pluginName": "fpp-matrixtools", + "label": "1", + "color": "red" + } + ] + } + } + } + } + } + }, + "/plugin/{RepoName}": { + "parameters": [ + { + "name": "RepoName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "tags": [ + "plugin" + ], + "summary": "Uninstall plugin", + "description": "Uninstall plugin {RepoName}.", + "responses": { + "200": { + "description": "Plugin uninstalled", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + }, + "get": { + "tags": [ + "plugin" + ], + "summary": "Get plugin information", + "description": "Get `pluginInfo.json` for installed plugin `{RepoName}`. An additional `updatesAvailable` field indicates whether the plugin has commits that have been fetched but not yet merged.", + "responses": { + "200": { + "description": "Plugin information", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "repoName": "fpp-matrixtools", + "name": "MatrixTools", + "author": "Chris Pinkham (CaptainMurdoch)", + "srcURL": "https://github.com/cpinkham/fpp-matrixtools.git", + "updatesAvailable": 0, + "versions": [ + { + "minFPPVersion": 0, + "maxFPPVersion": 0, + "branch": "master", + "sha": "" + } + ] + } + } + } + } + } + } + }, + "/plugin/{RepoName}/settings/{SettingName}": { + "parameters": [ + { + "name": "RepoName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "SettingName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "plugin" + ], + "summary": "Get setting from plugin", + "description": "Returns the value of setting `{SettingName}` from plugin `{RepoName}`.", + "responses": { + "200": { + "description": "Plugin setting value", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "SettingName": "SettingValue" + } + } + } + } + } + }, + "post": { + "tags": [ + "plugin" + ], + "summary": "Set setting for plugin", + "description": "Sets `{SettingName}` for plugin `{RepoName}` and returns the updated value.", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "SettingValue" + } + } + }, + "responses": { + "200": { + "description": "Plugin setting updated", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "SettingName": "SettingValue" + } + } + } + } + } + } + }, + "/plugin/{RepoName}/updates": { + "parameters": [ + { + "name": "RepoName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "plugin" + ], + "summary": "Check plugin for updates", + "description": "Check plugin `{RepoName}` for available updates by running `git fetch` in the plugin directory and checking for any unmerged commits.", + "responses": { + "200": { + "description": "Update check result", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "", + "updatesAvailable": 1 + } + } + } + } + } + } + }, + "/plugin/{RepoName}/upgrade": { + "parameters": [ + { + "name": "RepoName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "plugin" + ], + "summary": "Update plugin", + "description": "Pull in git updates for plugin `{RepoName}`. Supports an optional `?stream=true` query parameter for streaming output.", + "parameters": [ + { + "name": "stream", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When `true`, stream the upgrade output to the response instead of buffering it" + } + ], + "responses": { + "200": { + "description": "Plugin upgraded", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/proxies": { + "delete": { + "tags": [ + "proxies" + ], + "summary": "Delete all proxies", + "description": "Deletes all proxy entries by writing an empty `proxy-config.conf` and triggering an Apache graceful reload.", + "responses": { + "200": { + "description": "All proxies deleted", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [] + } + } + } + } + }, + "get": { + "tags": [ + "proxies" + ], + "summary": "Get list of proxy IPs", + "description": "Returns the list of IP addresses this FPP instance can proxy.", + "responses": { + "200": { + "description": "Current proxy list", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "host": "192.168.1.2", + "description": "Mega Tree" + }, + { + "host": "192.168.1.146", + "description": "Yard" + }, + { + "host": "192.168.1.148", + "description": "Left House" + } + ] + } + } + } + } + }, + "post": { + "tags": [ + "proxies" + ], + "summary": "Set proxy list", + "description": "Replaces the proxy list with the submitted array of `host`/`description` objects, validates each entry, and triggers an Apache graceful reload.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "host": "192.168.1.2", + "description": "Mega Tree" + } + ] + } + } + }, + "responses": { + "200": { + "description": "Updated proxy list", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "host": "192.168.1.2", + "description": "Mega Tree" + }, + { + "host": "192.168.1.146", + "description": "Yard" + }, + { + "host": "192.168.1.148", + "description": "Left House" + } + ] + } + } + }, + "400": { + "description": "No valid proxies provided", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "error": "No valid proxies provided" + } + } + } + } + } + } + }, + "/proxies/{ProxyIp}": { + "parameters": [ + { + "name": "ProxyIp", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "tags": [ + "proxies" + ], + "summary": "Remove proxy", + "description": "Removes a single IP address from the FPP proxy list.", + "responses": { + "200": { + "description": "Updated proxy list", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [] + } + } + } + } + }, + "post": { + "tags": [ + "proxies" + ], + "summary": "Add proxy", + "description": "Adds a single IP address to the FPP proxy list if it does not already exist.", + "responses": { + "200": { + "description": "Updated proxy list", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "host": "192.168.1.2", + "description": "Mega Tree" + } + ] + } + } + } + } + } + }, + "/proxy/{Ip}/{urlPart}": { + "parameters": [ + { + "name": "Ip", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "urlPart", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "proxy" + ], + "summary": "Get URL from remote FPP", + "description": "Fetches a URL on a remote FPP instance via server-side proxy to avoid CSP restrictions.", + "responses": { + "400": { + "description": "Invalid IP address", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "error": "Invalid IP address" + } + } + } + }, + "502": { + "description": "Proxy fetch failed", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "error": "Failed to fetch proxied URL" + } + } + } + } + } + } + }, + "/remoteAction": { + "post": { + "tags": [ + "remoteAction" + ], + "summary": "Proxy a command to remote FPP (v2 \u2014 JSON body)", + "description": "Proxies a named action to a remote FPP instance by IP address. Supported actions: `listUpgrades`, `reboot`, `restartFppd`, `upgradeOS`.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "ip": "192.168.1.100", + "action": "reboot" + } + } + } + }, + "responses": { + "400": { + "description": "Invalid action", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "error": "Invalid action given: badaction" + } + } + } + } + } + } + }, + "/remotes": { + "get": { + "tags": [ + "remotes" + ], + "summary": "Get all remote FPPs", + "description": "Returns the list of known remote FPP systems from `fppd` multiSync discovery, keyed by address. Addresses that can't serve as a useful command/plugin target are filtered out: - loopback (127.0.0.0/8, ::1) \u2014 the local box discovering itself. - IPv6 link-local (fe80::/10) \u2014 needs a host-specific zone id and can't be unicast by fppd anyway (MultiSync::SendUnicastPacket is IPv4-only). - IPv4 link-local / APIPA (169.254.0.0/16) \u2014 dropped when the same device also has a routable address; kept only when it is all the device has, so a link-local-only host stays selectable. Multiple routable addresses for one device are left intact.", + "responses": { + "200": { + "description": "Known remote FPP systems", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "192.168.1.10": "192.168.1.10 - remote-fpp", + "192.168.1.11": "192.168.1.11" + } + } + } + } + } + } + }, + "/schedule": { + "get": { + "tags": [ + "schedule" + ], + "summary": "Get schedules", + "description": "Returns the current FPP schedule configuration from `schedule.json`.", + "responses": { + "200": { + "description": "Current schedule entries", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "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 + } + ] + } + } + } + } + }, + "post": { + "tags": [ + "schedule" + ], + "summary": "Set schedule", + "description": "Saves the new schedule configuration to `schedule.json`.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "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 + } + ] + } + } + }, + "responses": { + "200": { + "description": "Saved schedule entries", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + { + "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 + } + ] + } + } + }, + "500": { + "description": "Failed to write schedule", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Unable to open schedule.json for writing." + } + } + } + } + } + }, + "/schedule/reload": { + "post": { + "tags": [ + "schedule" + ], + "summary": "Reload schedules", + "description": "Sends a reload command to `fppd` to re-read the schedule configuration.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Schedule reloaded", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/scripts": { + "get": { + "tags": [ + "scripts" + ], + "summary": "Get all scripts", + "description": "Returns a list of currently installed scripts.", + "responses": { + "200": { + "description": "List of installed script filenames", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "script1.sh", + "script2.sh" + ] + } + } + } + } + } + }, + "/scripts/installRemote/{category}/{filename}": { + "parameters": [ + { + "name": "category", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "scripts" + ], + "summary": "Install remote script", + "description": "Installs a remote script from the script repository.", + "responses": { + "200": { + "description": "Remote script installed", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/scripts/viewRemote/{category}/{filename}": { + "parameters": [ + { + "name": "category", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "scripts" + ], + "summary": "Get remote script", + "description": "Returns the source code of a remote script from the script repository.", + "responses": { + "200": { + "description": "Remote script source code", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "The content of the script as a string" + } + } + } + } + } + }, + "/scripts/{scriptName}": { + "parameters": [ + { + "name": "scriptName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "scripts" + ], + "summary": "Get a script", + "description": "Returns the source code of an installed script.", + "responses": { + "200": { + "description": "Script source code", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "The content of the script as a string" + } + } + } + } + }, + "post": { + "tags": [ + "scripts" + ], + "summary": "Update script", + "description": "Writes the `POST` request body to the file specified by `{scriptName}`.", + "responses": { + "200": { + "description": "Script saved", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "scriptName": "test.py", + "scriptBody": "#!/usr/bin/python\n\nprint(\"hi There Matt!\");\n" + } + } + } + } + } + } + }, + "/scripts/{scriptName}/run": { + "parameters": [ + { + "name": "scriptName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "scripts" + ], + "summary": "Run script", + "description": "Runs a locally installed script.", + "responses": { + "200": { + "description": "Script output", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "The output of the script as a String" + } + } + } + } + } + }, + "/sequence": { + "get": { + "tags": [ + "sequence" + ], + "summary": "Get all sequences", + "description": "Returns a list of all `*.fseq` sequence files.", + "responses": { + "200": { + "description": "List of sequence names", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "GreatestShow", + "StPatricksDay", + "Valentine" + ] + } + } + } + } + } + }, + "/sequence/current/step": { + "post": { + "tags": [ + "sequence" + ], + "summary": "Step a paused sequence", + "description": "If the sequence was paused via `sequence/current/togglePause`, steps the sequence forward one frame.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Sequence stepped", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/sequence/current/stop": { + "post": { + "tags": [ + "sequence" + ], + "summary": "Stop sequence", + "description": "Stops the currently playing sequence. Only valid if the sequence was started via `/api/sequence/{SequenceName}/start/{startSecond}`.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + }, + { + "name": "DEVELOPER ONLY", + "color": "#546e7a" + } + ], + "responses": { + "200": { + "description": "Sequence stopped", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/sequence/current/togglePause": { + "post": { + "tags": [ + "sequence" + ], + "summary": "Toggle play/pause on sequence", + "description": "Pauses or resumes the currently playing sequence. Only valid if the sequence was started via `/api/sequence/{SequenceName}/start/{startSecond}`.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + }, + { + "name": "DEVELOPER ONLY", + "color": "#546e7a" + } + ], + "responses": { + "200": { + "description": "Sequence play/pause toggled", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/sequence/{SequenceName}": { + "parameters": [ + { + "name": "SequenceName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "tags": [ + "sequence" + ], + "summary": "Delete sequence file", + "description": "Deletes the named `*.fseq` sequence file.", + "responses": { + "200": { + "description": "Sequence deleted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + }, + "get": { + "tags": [ + "sequence" + ], + "summary": "Download sequence", + "description": "Downloads the `*.fseq` file for the named sequence.", + "responses": { + "200": { + "description": "Raw FSEQ file download", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Sequence not found", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Not found: {SequenceName}" + } + } + } + } + }, + "post": { + "tags": [ + "sequence" + ], + "summary": "Uploads sequence file", + "description": "Uploads a new `*.fseq` sequence file.", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "(Raw FSEQ file data)" + } + } + }, + "responses": { + "200": { + "description": "Sequence uploaded", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Status": "OK", + "Message": "" + } + } + } + } + } + } + }, + "/sequence/{SequenceName}/meta": { + "parameters": [ + { + "name": "SequenceName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "sequence" + ], + "summary": "Get sequence metadata", + "description": "Returns `name`, `version`, `id`, `time`, and other details from the `*.fseq` file for the named sequence.", + "responses": { + "200": { + "description": "Sequence metadata", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Name": "GreatestShow.fseq", + "Version": "2.0", + "ID": "1553194098754908", + "StepTime": 25, + "NumFrames": 10750, + "MaxChannel": 84992, + "ChannelCount": 84992 + } + } + } + }, + "404": { + "description": "Sequence not found", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Not found: {SequenceName}" + } + } + } + } + } + }, + "/sequence/{SequenceName}/start/{startSecond}": { + "parameters": [ + { + "name": "SequenceName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "startSecond", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "sequence" + ], + "summary": "Start sequence at time", + "description": "Starts the given sequence at the specified time frame. Only intended for testing. In most situations, use the \"Start Playlist\" command from the command API and pass the sequence name as the playlist name.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + }, + { + "name": "DEVELOPER ONLY", + "color": "#546e7a" + } + ], + "responses": { + "200": { + "description": "Sequence started", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "SequenceName": "single_line.fseq", + "startSecond": "9" + } + } + } + } + } + } + }, + "/settings": { + "get": { + "tags": [ + "settings" + ], + "summary": "Get all settings.json", + "description": "Returns the `settings.json` metadata file as a JSON list of settings.", + "responses": { + "200": { + "description": "All settings metadata", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "settingGroups": { + "BBBLeds": { + "description": "BeagleBone LEDs", + "platforms": [ + "BeagleBone Black" + ], + "settings": [ + "BBBLeds0", + "BBBLeds1", + "BBBLeds2", + "BBBLeds3", + "BBBLedPWR" + ] + } + }, + "settings": { + "alwaysTransmit": {} + } + } + } + } + } + } + } + }, + "/settings/{SettingName}": { + "parameters": [ + { + "name": "SettingName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "settings" + ], + "summary": "Get value of setting", + "description": "Get info about a particular setting, including its current `value`.", + "responses": { + "200": { + "description": "Setting metadata and current value", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "AudioFormat", + "description": "Audio Output Format", + "tip": "The Audio Format generated by the decoder", + "level": 1, + "restart": 2, + "default": 0, + "type": "select", + "options": { + "Default": 0, + "MP3": 1, + "Ogg": 2, + "Flac": 3 + } + } + } + } + } + } + }, + "put": { + "tags": [ + "settings" + ], + "summary": "Set value for setting", + "description": "Sets the value for a specific setting.", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "0" + } + } + }, + "responses": { + "200": { + "description": "Setting saved", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/settings/{SettingName}/jsonValueUpdate": { + "parameters": [ + { + "name": "SettingName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "put": { + "tags": [ + "settings" + ], + "summary": "Set sub-value", + "description": "Updates a sub-value held as JSON within a setting's value. Only valid for settings stored as JSON.", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "raw json of hierarchy to update" + } + } + }, + "responses": { + "200": { + "description": "Sub-value updated", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/statistics/usage": { + "delete": { + "tags": [ + "statistics" + ], + "summary": "Resets statistics cache", + "description": "Deletes the cached statistics file.", + "responses": { + "200": { + "description": "Statistics cache cleared", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + }, + "get": { + "tags": [ + "statistics" + ], + "summary": "Get statistics", + "description": "Returns the statistics file that will be shared with the development team 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.", + "parameters": [ + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "bypass cache" + } + ], + "responses": { + "200": { + "description": "Usage statistics payload", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "uuid": "6ba176e7-da7f-49f4-8b27-edb5bd9ff616", + "systemInfo": { + "mqtt": { + "configured": true, + "connected": true + }, + "fppdStatus": "running", + "fppdMode": "player", + "fppdUptimeSeconds": 3436, + "platform": "Debian", + "version": "4.x-master-914-gebda8520", + "majorVersion": 4, + "minorVersion": 1000, + "typeId": 1, + "branch": "master", + "utilization": { + "CPU": 2.2, + "Memory": 15.9, + "Uptime": "7 days" + } + }, + "capeInfo": { + "type": "None" + }, + "files": { + "sequences": { + "cnt": 2, + "bytes": 19025632 + } + }, + "models": { + "count": 0 + } + } + } + } + } + } + }, + "post": { + "tags": [ + "statistics" + ], + "summary": "Publsh statistics", + "description": "Transmits the statistics payload to the remote stats server configured in the `statsPublishUrl` setting.", + "responses": { + "200": { + "description": "Statistics transmitted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "uuid": "M2-xxxxxxxx-f67f-930d-56ee-7xxxxxxxxxx" + } + } + } + } + } + } + }, + "/system/fppd/restart": { + "post": { + "tags": [ + "system" + ], + "summary": "Restart fppd process", + "description": "Restarts the `fppd` process. Pass `?quick=1` to reload some configuration without a full restart.", + "parameters": [ + { + "name": "quick", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "When `1`, send a reload signal to a running fppd instead of a full stop/start" + } + ], + "responses": { + "200": { + "description": "fppd restarted", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/system/fppd/skipBootDelay": { + "post": { + "tags": [ + "system" + ], + "summary": "Skip boot delay", + "description": "Skips the current boot delay by creating a skip flag file, allowing FPP startup to proceed immediately.", + "responses": { + "200": { + "description": "Boot delay skip requested", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "message": "Boot delay skip requested" + } + } + } + } + } + } + }, + "/system/fppd/start": { + "post": { + "tags": [ + "system" + ], + "summary": "Start fppd", + "description": "Starts the `fppd` process idempotently (if it isn't already running).", + "responses": { + "200": { + "description": "fppd started", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/system/fppd/stop": { + "post": { + "tags": [ + "system" + ], + "summary": "Stop fppd", + "description": "Stops the `fppd` process if it is running.", + "responses": { + "200": { + "description": "fppd stopped", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/system/info": { + "get": { + "tags": [ + "system" + ], + "summary": "Get system info", + "description": "Returns basic information about the system.", + "responses": { + "200": { + "description": "System information", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "HostName": "FPPPi", + "HostDescription": "", + "Platform": "Raspberry Pi", + "Variant": "Pi 4", + "Mode": "player", + "Version": "6.0", + "Branch": "master", + "OSVersion": "v2022-02", + "OSRelease": "Raspbian GNU/Linux 11 (bullseye)", + "channelRanges": "1545-84479", + "majorVersion": 6, + "minorVersion": 1000, + "typeId": 13, + "uuid": "M1-10000000AAAAAAA", + "Utilization": { + "CPU": 0.12, + "Memory": 1.96, + "Uptime": "11 days" + }, + "Kernel": "5.10.92-v7l+", + "LocalGitVersion": "b998f65", + "RemoteGitVersion": "ed62c12", + "UpgradeSource": "github.com", + "IPs": [ + "192.168.3.84" + ] + } + } + } + } + } + } + }, + "/system/packages": { + "get": { + "tags": [ + "system" + ], + "summary": "Get all system packages", + "description": "Returns a list of all installed and available OS package names via `apt list --all-versions`.", + "responses": { + "200": { + "description": "List of OS package names", + "content": { + "application/json": { + "schema": { + "type": "array" + }, + "example": [ + "apache2", + "ffmpeg", + "php" + ] + } + } + } + } + } + }, + "/system/packages/info/{packageName}": { + "parameters": [ + { + "name": "packageName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "system" + ], + "summary": "Get system package information", + "description": "Returns description, dependencies, and installation status for the specified OS package.", + "responses": { + "200": { + "description": "Package information", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "Description": "The FFmpeg multimedia framework", + "Depends": "libavcodec58, libavformat58", + "Installed": "Yes" + } + } + } + } + } + } + }, + "/system/reboot": { + "post": { + "tags": [ + "system" + ], + "summary": "Reboot the operating system", + "description": "Reboots the operating system.", + "responses": { + "200": { + "description": "Reboot initiated" + } + } + } + }, + "/system/releaseNotes/{version}": { + "parameters": [ + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "system" + ], + "summary": "Get release notes", + "description": "Returns release notes for the specified FPP version tag from the GitHub releases API.", + "responses": { + "200": { + "description": "Release notes", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "draft": false, + "prerelease": false, + "body": "...", + "published_at": "2026-01-08T03:09:40Z" + } + } + } + } + } + } + }, + "/system/shutdown": { + "post": { + "tags": [ + "system" + ], + "summary": "Shutdown the operating system", + "description": "Executes a clean shutdown of the operating system.", + "responses": { + "200": { + "description": "Shutdown initiated" + } + } + } + }, + "/system/status": { + "get": { + "tags": [ + "system" + ], + "summary": "Get system status", + "description": "Returns `fppd`, network, current playlist, schedule, utilization, host, version, and MQTT status. Pass an optional array of IP addresses (e.g. `&ip[]=192.168.0.1&ip[]=192.168.0.2`) to query remote instances instead.", + "responses": { + "200": { + "description": "System status", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "fppd": "running", + "status": 1, + "status_name": "playing", + "mode": 2, + "mode_name": "player", + "current_playlist": { + "count": "4", + "playlist": "Test1", + "type": "pause", + "index": "2" + }, + "volume": 70, + "wifi": [], + "interfaces": [] + } + } + } + } + } + } + }, + "/system/updateStatus": { + "get": { + "tags": [ + "system" + ], + "summary": "Get fpp upgrade status", + "description": "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.", + "responses": { + "200": { + "description": "FPP upgrade status", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "branchUpgradeAvailable": false, + "branchUpgradeTarget": "", + "branchUpgradeVersion": "", + "isMajorVersionUpgrade": false, + "commitUpdateAvailable": false, + "remoteCommit": "ece480e86b7dd8f2d013248e8f99bb0e8baac197", + "currentBranch": "master", + "localCommit": "ece480e86", + "isEndOfLife": false, + "latestMajorVersion": 9 + } + } + } + } + } + } + }, + "/system/volume": { + "get": { + "tags": [ + "system" + ], + "summary": "Get volume", + "description": "Returns the current volume if `fppd` is running, or the `Volume` setting value if not.", + "responses": { + "200": { + "description": "Current volume", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "method": "FPPD", + "volume": 70 + } + } + } + } + } + }, + "post": { + "tags": [ + "system" + ], + "summary": "Set volume", + "description": "Sets the system volume. The new level should be passed as a JSON body.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "volume": 34 + } + } + } + }, + "responses": { + "200": { + "description": "Volume set", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK", + "volume": 34 + } + } + } + } + } + } + }, + "/testmode": { + "get": { + "tags": [ + "testmode" + ], + "summary": "Get Test Mode state", + "description": "Returns the current Test Mode configuration for this instance.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "responses": { + "200": { + "description": "Current Test Mode configuration", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "mode": "RGBChase", + "subMode": "RGBChase-RGB", + "cycleMS": 1000, + "colorPattern": "FF000000FF000000FF", + "enabled": 1, + "channelSet": "1-520", + "channelSetType": "channelRange" + } + } + } + } + } + }, + "post": { + "tags": [ + "testmode" + ], + "summary": "Set Test Mode configuration", + "description": "Sets the current Test Mode configuration for this instance.", + "x-badges": [ + { + "name": "FPP REQUIRED", + "color": "#c62828" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "mode": "RGBChase", + "subMode": "RGBChase-RGB", + "cycleMS": 1000, + "colorPattern": "FF000000FF000000FF", + "enabled": 1, + "channelSet": "1-520", + "channelSetType": "channelRange" + } + } + } + }, + "responses": { + "200": { + "description": "Test mode updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "status": "OK" + } + } + } + } + } + } + }, + "/time": { + "get": { + "tags": [ + "time" + ], + "summary": "Get current time", + "description": "Returns the current system time as a formatted string.", + "responses": { + "200": { + "description": "Current system time", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "time": "Tue Apr 02 08:06:34 EDT 2019" + } + } + } + } + } + } + } + } +} \ No newline at end of file
EndpointDescriptionInput JSONOutput JSON
%s%s%s%s