Kiosk Satellite Remote Management API¶
Every device runs an embedded HTTP server (default port 2324, configurable; kept for Fully Kiosk familiarity) serving:
- The remote admin SPA (
remote-ui/, bundled into app assets) at/. - A REST API under
/api/. - A WebSocket at
/api/wsfor live state, events, and log tailing.
On iOS the server runs while the app is foreground, which is always true in kiosk use.
Devices advertise over mDNS as _kiosk-satellite._tcp while the server runs,
which is how the kiosk switcher below finds them.
Enabling¶
The server starts only when remote.enabled is on and remote.password
is set. Three ways to get there: the setup wizard's optional admin-password
field, Settings → Device → Remote Administration on the device (swipe from
the left edge → Settings),
or an Android provisioning intent:
adb shell am start -n me.jxl.kiosk_satellite/.MainActivity \
--es ks.provision '"{\"remote.enabled\":true,\"remote.password\":\"secret\"}"'
Reaching a kiosk by name¶
Every kiosk with its remote admin on answers to a hostname on the local
network, so the admin opens at http://<hostname>.local:2324 from a laptop
with no IP address to remember. The mDNS name setting under Settings →
Device, right under Device name, holds it. It is filled in from the device
name as a DNS label under ks- (the device name "Kitchen Tablet" becomes
ks-kitchen-tablet, answering to ks-kitchen-tablet.local), the same name
the ESPHome node takes on a fresh install, so the field holds the real name
to copy. Renaming the device leaves it alone; clear the field to take the
device name again. A typed name is slugified the same way: lowercase
letters, digits and hyphens. The Access card under Remote Administration, on the device and
in the remote admin, shows the address by name next to the one by IP.
| How | The kiosk announces an A record for <hostname>.local over mDNS every 30 seconds and answers queries for it with that record alone. A query that asks for a unicast reply (the first one macOS and iOS send) gets one straight back, and a plain DNS query sent to the multicast group from an ordinary port (Android's own resolver, dig @224.0.0.251 -p 5353) gets a matching unicast reply too. That is what makes the name resolve on a Wi-Fi network that filters multicast toward its clients: the queries still reach the kiosk, and the reply comes back unicast. |
| When | While Remote management is on with a password set, with or without Find other kiosks. |
| Where it resolves | Any machine that resolves .local over mDNS: macOS, iOS, Android with system mDNS support, Windows 10 and later and Linux with Avahi (or systemd-resolved with mDNS turned on). Android versions without system mDNS support still need the IP. Multicast does not cross VLANs without a reflector. |
| Two kiosks, one name | Both answer, and a browser lands on either. The log warns when another device announces this name at a different IPv4 address, even with Find other kiosks off on either kiosk. Give one of them a different mDNS name. |
| Port 5353 | Answering queries takes the mDNS port. Where something on the device holds it exclusively the kiosk still announces, and only resolvers that cache announcements they did not ask for (Avahi does) find it. |
| Per device | The mDNS name never syncs from a fleet leader, and a settings import that clones a kiosk drops it. |
Kiosk switcher¶
With several kiosks on one network, the device name under the logo in the remote admin becomes a dropdown. It opens Switch kiosk, a list of every kiosk heard on the network: this device first, then the others by name, each with its address and version. Picking one opens that kiosk's remote admin in the same tab, on the page you were on. A second-level page the other kiosk does not have (gated off by its own settings) lands on its parent tab. Its own login card shows first if its password differs.
| How they find each other | Each kiosk announces ks-<id>._kiosk-satellite._tcp.local over mDNS with its name, version and admin port, every 30 seconds and on a query, and listens for the others. Raw multicast packets, not NsdManager, which never calls back on Fire OS and some LineageOS builds. |
| What is listed | Kiosks with Remote management on, a password set and Find other kiosks on, on the same network segment. Multicast does not cross VLANs. |
| Switch | Find other kiosks under Settings → Device → Remote Administration, on by default. Off, the kiosk neither announces nor listens, and the dropdown stays plain text. |
| Command | fleet answers the same list: {enabled, devices: [{id, name, version, address, port, url, self}]}. The WebSocket carries a fleet event on every change. |
| Port 5353 | Hearing the others needs the mDNS port. Where something on the device holds it exclusively the kiosk still announces, and the log says the others will not be heard. |
Fleet Management¶
With the switcher in place, one kiosk can lead the others: it pushes the settings categories each follower was given to it, holds off while their versions differ and can update the fleet. The page, the sync rules and the fleet endpoints are in Fleet Management.
Authentication¶
- A device password (set in the wizard; required before the server starts).
- REST:
Authorization: Bearer <token>obtained fromPOST /api/login {password}. Tokens are HMAC-signed (stateless) with a persisted secret and expire after 7 days, so a session survives the app/kiosk restarting. - Automations that cannot redo the login dance pass
ttl_daysto get a long-lived token (clamped to 10 years):POST /api/login {password, ttl_days: 3650}. Tokens are stateless, so changing the admin password does not revoke ones already issued — treat a long-lived token like a password. - WS:
?token=query parameter. - Failed logins are rate-limited (exponential backoff per client IP).
GET /api/healthis the one unauthenticated endpoint: it exists for external monitoring to poll, and a monitor cannot do a login dance. It serves read-only hardware facts only.- Optional TLS with a self-signed cert (off by default; LAN-only assumption documented).
REST surface¶
The API is a thin adapter over the internal CommandRegistry, the same
commands the JS API and the ESPHome entities use. Everything administrable in the app
is administrable here by construction.
| Endpoint | Method | Description |
|---|---|---|
/api/login |
POST | {password} → {token}. Optional ttl_days for a long-lived automation token (max 3650) |
/api/info |
GET | Device info, app version, battery, screen, current URL |
/api/health |
GET | The Device Info tab's Hardware section as one JSON object: identity, addresses, battery (null on a device without one), screen, RAM, storage, CPU usage and temperature, and uptimes (uptime.app and uptime.network, seconds; network is null while offline and starts counting at app start at the earliest). Meant for external monitoring to poll, so it is the one endpoint that needs no token |
/api/settings |
GET | All setting definitions + current values |
/api/settings |
PATCH | {key: value, ...} partial update |
/api/settings/export |
GET | Full config as JSON (for provisioning) |
/api/settings/import |
POST | Apply exported config. Query param: adoptIdentity (default on) keeps the dump's device name, ESPHome node name and Sendspin player id, for restoring the same device; pass 0 when provisioning a second device from another's dump, so it keeps its own identity (and its own Voice Satellite selection) instead of the two fighting over one ESPHome device and one Sendspin player |
/api/config/export |
GET | Full backup: every setting (secrets included) plus the page's localStorage. Also carries deviceName and exportedAt, which name the downloaded file (ks-backup_<device>_<YYYYMMDD>_<HHmmss>.json) and keep it identifiable afterwards |
/api/config/import |
POST | Apply a full backup. Query params: adoptIdentity (default on) takes over the backup's device name and ESPHome node name, for replacing the original device — pass 0 when cloning a second device so it keeps its own identity; importLocalStorage (default on) applies the page's saved data including the Voice Satellite selection — pass 0 so the device answers as its own satellite |
/api/commands |
GET | List registered commands + param schemas |
/api/commands/<name> |
POST | Execute a command with JSON params |
/api/screenshot |
GET | JPEG of the current screen (PNG placeholder while it is off). The capture also feeds the Screenshot entity and Last screenshot over ESPHome |
/api/media/artwork |
GET | The shown track's cover art, fetched by the device (which trusts the Sendspin and Music Assistant hosts a browser would not), labelled by its bytes. 404 with no track or no art. X-Artwork-Url carries the source URL. |
/api/camera/snapshot |
GET | The latest device-camera frame as JPEG (404 until one has been captured). X-Snapshot-At carries the capture time as ISO 8601 UTC. Serves the cached frame; it never triggers a capture (use the takeCameraSnapshot command for that). |
/api/files/download |
GET | Stream a device file. Query params: root (shared or app), path (relative to the root) |
/api/files/upload |
POST | Write the raw request body to a device file, same root/path query params. Parent folders are created |
/api/fleet/identity, /api/fleet/invite, /api/fleet/invite/<nonce> |
GET, POST, GET | Fleet Management's public face, for a kiosk with no token here: who this kiosk is, an invitation to follow (answered on the kiosk screen, never here) and what became of one. See Fleet Management |
/api/fleet/status, /api/fleet/apply, /api/fleet/leave |
GET, POST, POST | The follower's side of the fleet: opened by the fleet token a follower mints on accepting, which is good for these, getUpdateStatus, checkUpdateNow and installUpdate and nothing else, only while it names this kiosk's leader |
/api/logs |
GET | Recent app log ring buffer |
/api/console |
GET | Current WebView JS console buffer |
The File Manager tab drives these plus the fileRoots, fileList and
fileDelete commands. The shared root is the device's shared storage and
needs the "All files access" grant (a settings screen on the device, offered
from the tab); the app root is the app's own folder and always works. Paths
are canonicalized against their root, so .. cannot escape it.
Representative commands (POST /api/commands/<name>): loadUrl {url},
loadDashboard {dashboard}, loadStartUrl (back to the configured
Start URL), reload, screenOn / screenOff / isScreenOn (screenOn {path: "activity"}
skips the wake lock and wakes through the Activity route only, to tell
which of the two works on a panel that stays dark),
setBrightness {level} (turns Default brightness, or Maximum brightness
with adaptive brightness on; ceiling:
true sets the bright-room level for the session instead, which is what
the screensaver does; getBrightness reads the same level, {ceiling:
true} the session's, {panel: true} what the panel shows),
startScreensaver / stopScreensaver /
isScreensaverActive,
postponeScreensaver (reset the idle timer, dismissing a showing
screensaver first), nextScreensaverSlide / previousScreensaverSlide
(step a showing photo-mode slideshow; a no-op for other modes, the
result says whether anything stepped),
setWakeWordActive {active}, showCameraView {viewId},
hideCameraView, getCameraViewState ({active, viewId, viewName,
focusedCameraId}), cameraGetConfig, restartApp, rebootDevice
(restart the whole device; only as device owner or through a granted
Shizuku connection, which getDeviceRebootSupport reports as
{supported, route, reason}), tts {text},
launchApp {package} (open another Android app over the kiosk),
bringToFront (come back in front of it), installedApps (every
launchable app as [{package, label}]), immichAlbums,
immichPeople and immichTags (the Immich screensaver's albums, named
people and tags as [{id, name}], for its source and filter pickers), showAppLauncher /
hideAppLauncher (the app launcher overlay;
showAppLauncher refuses while the App Launcher is disabled in
settings), homeLauncherStatus, acquireHomeRole and releaseHomeRole
(the Home Launcher role: its live state, starting the
takeover, and giving the role back; releaseHomeRole is the recovery
lever and works with no kiosk screen up, as long as the process runs), showNotification {message, title, duration, type, chime, scale, icon, chime_file, volume, image} and
dismissNotification {id} (a message over whatever is on screen, the
screensaver included; message is read as Markdown and title takes
its inline part, see esphome.md; duration is seconds, 0 stays until dismissed,
omitted uses 30, chime defaults to on, and scale draws the card
larger, 1 to 4 with decimals, icon takes any Material Design Icon
name (mdi:washing-machine) in place of the one the type picks,
chime_file names a file in the kiosk's sounds folder (leak.mp3, see
esphome.md) to play in place of the one
picked in the kiosk's settings, and volume sets how loud it plays, 0
to 1, apart from the media and assistant volumes, with 0 or omitted
meaning the Notification volume setting, and image puts a picture
under the text, an http(s) URL or a path on the Home Assistant server
such as /api/camera_proxy/camera.doorbell, fetched with the kiosk's
own Home Assistant token. showNotification answers
with the id to dismiss later, notifications stack newest on top up to
four, and dismissNotification without an id clears them all. Home
Assistant setups can push the same thing as an ESPHome action, see
esphome.md).
Calling from Home Assistant automations¶
The Fully Kiosk pattern — one URL per action, fired from an automation —
maps onto two rest_command entries and a token obtained once. Get the
long-lived token from any machine on the LAN:
curl -X POST http://<device-ip>:2324/api/login \
-H 'Content-Type: application/json' \
-d '{"password": "<admin password>", "ttl_days": 3650}'
Paste the returned token into configuration.yaml (or a !secret):
rest_command:
tablet_open_doorbell:
url: "http://<device-ip>:2324/api/commands/launchApp"
method: post
headers:
authorization: "Bearer <token>"
content_type: "application/json"
payload: '{"package": "com.mcu.reolink"}'
tablet_back_to_dashboard:
url: "http://<device-ip>:2324/api/commands/bringToFront"
method: post
headers:
authorization: "Bearer <token>"
An automation then calls rest_command.tablet_open_doorbell when the
doorbell rings and rest_command.tablet_back_to_dashboard when done.
launchApp leaves the kiosk running behind the other app, so coming back
lands on the live dashboard, not a reload. With ESPHome
kiosk entities exposed, the return half needs no REST at all: the device
carries a Bring to front button entity.
WebSocket¶
JSON messages, {type, ...}:
- Server → client:
state(full snapshot on connect: device identity, battery, brightness, plusscreenOn,screensaverActiveandcameraView {active, viewId, viewName}, the same shapeGET /api/inforeturns),event(bus events: motion, face, wake word, screen, screensaver, camera view, navigation;screenon/screenoff,screensaverstart/screensaverstopandcameravieware the diffs to the snapshot's three states),log(app log lines),console({type: 'console', level, message, time}, the WebView's JavaScript console, streamed live so you can watch a wall-mounted kiosk's page logs remotely; fetch history first fromGET /api/console). - Client → server:
subscribe {topics: ['state','events','logs']},command {name, params}(same registry as REST).
Remote UI¶
The admin UI is a vanilla-JS single-page app: the page
at app/remote-ui/index.html
holds the markup, and the stylesheet plus ES modules live under
app/remote-ui/static/. Before Flutter
bundles them, app/tool/build_remote_ui.mjs minifies each file on its own
into app/assets/remote-ui/, which is generated and not tracked. Gradle
runs the script on every APK build and installs its one dependency,
esbuild, on the first run. Run npm run build from app/ by hand before
flutter test, since the tests that serve the admin read the bundle. The
server serves the page at / and the files at /static/<name>, gzipped
for browsers that accept it and discovered from the asset manifest, so
adding a module is just adding the file. Tabs: Overview (a Needs attention card for an update
to install, a missing grant a switched-on feature needs, a lost Home
Assistant connection or a stopped wake word engine, hidden while there is
nothing; the screenshot with a badge while the panel is dark, on the
screensaver or showing a camera view, a Still or Live toggle, full size and
download; status tiles for Home Assistant, Voice Satellite, ESPHome, Media
Player, the service and updates, each opening its page; a Now playing card
while the media player has a track; quick controls, brightness and master
volume. The screen, screensaver and camera view controls are one tile each,
relabelled by the device's state, Screen off while the panel is lit and
Screen on once it is dark, Start screensaver or Dismiss
screensaver, Show camera view, which picks a view, or Dismiss camera
view), Settings (rendered from the declarative setting definitions),
Console (live JS console over WS), Logs. It talks only to the REST/WS API
above (no privileged path), so it doubles as the API's reference client.
Caching: at startup the server hashes the page and every static file into
one 12-hex version, stamps it into the page's ?v= references and into
every from './x.js' import specifier it serves, and answers static files
with Cache-Control: immutable and the page itself with no-store. Any
change to any file changes the hash, so browsers cache aggressively yet can
never run a stale mix of modules.