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/.ProvisionActivity \
--es ks.provision '"{\"remote.enabled\":true,\"remote.password\":\"secret\"}"'
The payload takes any setting, in the same JSON the settings import
accepts. Only the adb shell can send it: Android refuses the intent from
other apps on the device. Older builds took the extra on
.MainActivity, which now ignores it.
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.
Over HTTPS with an imported certificate it shows the first DNS name that
certificate covers instead, since the browser rejects that certificate
under .local. The kiosk still answers to its .local name.
| 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 discovered or saved in the fleet: 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 by itself. Through an mDNS reflector on the router it does, and each kiosk is listed under the address its own announcement carries, not the router's, so calls and the switcher reach it as long as the VLANs route to each other. |
| Saved fleet members | Accepted members remain listed without multicast. Leaders store their followers and send the member directory to each follower. Discovery refreshes known addresses. Opening another kiosk still requires a reachable admin endpoint. |
| HTTPS with an imported certificate | A kiosk serving HTTPS with an imported certificate also announces the first DNS name that certificate covers, skipping wildcard and .local names. The switcher opens it by that name instead of its IP address, so the browser accepts the certificate. The name has to resolve to the kiosk on your network. A kiosk with only a wildcard certificate or the generated one is opened by its IP address. |
| Behind a reverse proxy | Admin URL under Settings → Device → Remote Administration takes an address such as https://kiosk.example.com, with an optional port and no path. The kiosk announces it, and the switcher opens it ahead of the certificate's name and the IP address. Blank by default. Fleet sync and the intercom keep using the IP address. It never syncs from a fleet leader. |
| 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}]}, plus adminUrl on a kiosk that has one. 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. It sendsAccess-Control-Allow-Origin: *, so a web page on any origin can read it.- Optional HTTPS with a device certificate. Off by default. Plain HTTP is intended for a trusted LAN.
HTTPS and certificates¶
Turn on Use HTTPS in Device > Remote Administration to encrypt the admin page, REST API and WebSocket on the existing admin port. A confirmation dialog shows the new address in a copyable box before either protocol change. Confirm applies it and reloads the browser at that address. Cancel keeps the current protocol. A self-signed certificate produces a browser warning until you trust it. You may need to log in again because HTTP and HTTPS have separate browser storage. The WebSocket uses wss:// automatically. The Home Assistant Admin URL sensor reports HTTPS. The ESPHome Visit link is omitted because that link assumes HTTP.
The Device > TLS page manages the device certificate. Encryption switches live on their feature pages. See TLS encryption for Remote Administration, camera and intercom setup:
- Download public certificate in the remote admin or Copy public certificate on the device exports only the public certificate.
- Renew certificate renews the built-in certificate with the same private key and current hostname and IP addresses. Browsers that trust a specific certificate may need the renewed certificate installed.
- Import certificate accepts a PEM server certificate chain and its matching unencrypted EC or RSA private key. Import from the device screen or through HTTPS. The app validates the material before replacing the current identity. Imported certificates are renewed by their issuer and must be imported again before they expire.
- Replace certificate generates a new private key and certificate. Browsers may ask you to accept the new certificate. Other kiosks reconnect automatically.
On Android, private keys are encrypted with an Android Keystore key and stored outside Android backup. They are never returned by the remote API or included in configuration exports. Each device keeps its own identity when a configuration is cloned. The generated certificate lasts one year. While any TLS setting is on, the app checks every six hours and renews generated certificates within 30 days of expiration. Renew manually after changing the hostname or when you need a new IP address included in the certificate.
Certificate changes restart encrypted listeners and disconnect active encrypted viewers. Plain listeners are unaffected. Failed certificate loading leaves the encrypted listener stopped with an error. It never opens a plaintext replacement. The device's local settings remain available for recovery.
The authenticated command API exposes tlsCertificate, renewTlsCertificate, replaceTlsIdentity and importTlsCertificate. Certificate status includes the PEM certificate, SHA-256 certificate fingerprint and expiration. Import accepts certificate and privateKey.
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 (width, height, density, orientation and rotation in degrees from the panel's natural orientation), RAM, storage, CPU usage, clock (cpu.clock, MHz) and temperature, the system WebView (webview.package and webview.version), the network link and uptimes. link.type is ethernet, wifi, cellular, vpn or other, and on Wi-Fi link adds rssi (dBm), speedMbps and frequencyMhz, each null when Android does not know it. link is null while offline and never carries the network name, which needs a location permission. uptime.app, uptime.device and uptime.network are in seconds since the app started, the device booted and the network came up. network is null while offline and starts counting at app start at the earliest. leader is true when the kiosk leads a fleet, and follows is the name of the leader it follows, null when it follows none. Meant for external monitoring to poll, so it is the one endpoint that needs no token. Any web page can read it, so a page of your own can list your kiosks by name and version |
/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/fleet/export |
GET | The full backup of this kiosk and every follower it leads, in one file. See Fleet Management |
/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/update/upload |
POST | Take in a Kiosk Satellite APK as the raw request body, for a kiosk that can reach neither GitHub nor a custom repository. The kiosk reads package, version and build out of it and refuses another package, an older build or a file its cache cannot hold twice. Answers {version, buildNumber, size, currentVersion, currentBuild}. Nothing installs until installUploadedApk is called; getUpdateStatus reports the waiting file under uploaded and installing while the hand-off runs, then the outcome in lastOutcome. See Updates |
/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 or with fleetAccept and fleetDecline in its remote admin) and what became of one. See Fleet Management |
/api/fleet/status, /api/fleet/apply, /api/fleet/leave, /api/fleet/roster |
GET, POST, 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, installUpdate, /api/update/upload and installUploadedApk and nothing else, only while it names this kiosk's leader |
/api/intercom/identity, /api/intercom/call, /api/intercom/call/<id>, /api/intercom/audio/<id> |
GET, POST, POST, WebSocket | The intercom's wire between kiosks: who this kiosk is (public), a call or broadcast coming in, the answer going back and the voice socket. All but the identity carry a token signed with the shared intercom key, never an admin token |
/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.
The soundDiagnostics command captures and replays a TTS response for Android
playback troubleshooting. See TTS playback diagnostics.
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),
showLinkPage {url, hold} / hideOverlayPage (a web page with a close
button over the dashboard, the surface a tapped dashboard link gets;
hold: true turns hold mode on with the page and off when it goes; the
open_url and close_url ESPHome actions
call the same two),
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, and shows only the Allowed Apps while Kiosk Mode is on), 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¶
Connect to /api/ws?token=<admin-token>. Messages are JSON objects with a
type. Fleet tokens cannot use this endpoint. The server sends a state
snapshot on connection with device identity, battery, brightness,
screenOn, screensaverActive, cameraView, nowPlayingShown,
intercomShown and currentUrl. Subscribers to events also get
{"type":"fullscreen-view","view":"nowPlaying","shown":true} whenever the
Now Playing view (nowPlaying) or the intercom's roster or call screen
(intercom) comes up or goes.
Subscribe to the data this client needs. Each subscribe replaces the
previous topic set. An empty list unsubscribes from all live data. Clients
that never subscribe retain the original event and log feed.
{"type":"subscribe","id":1,"topics":["settings","events","stats"]}
{"type":"command","id":2,"name":"getVolume","params":{}}
{"type":"settings","id":3,"values":{"screensaver.mode":"clock"}}
{"type":"get","id":4,"name":"info"}
Commands and settings writes return {"type":"result","id":...} with
ok and the command's data or error. Settings writes use the same
validation as PATCH /api/settings and return rejected and errors.
A get request answers with the same data the matching HTTP read
returns: info (GET /api/info), settings (GET /api/settings),
console (GET /api/console) and logs (GET /api/logs), so a
connected client needs no HTTP requests beyond the binary endpoints.
Replies may arrive out of order. Match them by id. A disconnected request
has an unknown outcome, so clients should read current state after
reconnecting instead of replaying writes.
| Topic | Server message |
|---|---|
settings |
settings {snapshot, settings, subpageHints}. The first subscription sends the current schema and values with snapshot: true and the second-level page hints. Later messages contain changed definitions only. Secrets stay masked as __set__ or an empty string |
events |
event {event, data} for screen, screensaver, camera view, fleet and other public device events |
stats |
stats {battery, charging, cpu, temp} while subscribed |
logs |
log {entry} for app log entries |
console |
console {level, message, time} for the dashboard's JavaScript console |
brightness, lightlevel, micLevel, wakeword-state |
The matching message type. A micLevel subscription holds the microphone meter until it is removed or the connection closes |
ha, voice, media, media-players, sonos, plugins, plugin-tiles, plugin-settings, fleet, fleetsync, intercom, location, person, volume, audio, update, camera-snapshot, cameras, gestures |
update {topic} when the manager behind it reports a change. Refresh the relevant status command |
rtsp, bluetooth, bluetooth-nearby, service, home-role, artwork-cache, filter |
update {topic, results} when diagnostic values change. results contains status command responses keyed by command name |
Settings changes are batched for 100 milliseconds. Events with no
subscribers do no serialization. A settings write is not a status
change: each manager announces its own status when it moves (the
service when its reasons change, Home Assistant when its connection
does, the player when it starts, stops or connects), and the few
settings a status command reads straight from the store (ha.url,
ha.token, sendspin.*) map to that command's topic. Native
diagnostics without change callbacks share one observer across all
viewers and stop when the last viewer unsubscribes. A manager's
announcement re-samples them at once, and unchanged samples are not
transmitted, so a bluetooth or service update always carries the
results that moved. Binary camera and screenshot data stay on their
existing HTTP endpoints.
The admin opens the socket first and reads everything it needs to build
the page through it. It subscribes to visible panels, releases their
observers when the page is hidden and reads fresh state when the page
returns. Settings updates continue into a bounded queue while hidden and
render on return. A reconnect subscribes again to recover changes missed
during the outage, and a state snapshot that reports a different
appVersion or buildNumber than the page was loaded against (the app
restarted on an update) shows a notice and reloads the page.
While an established connection is away the page is covered by a
"Reconnecting" notice and resumes on its own when the socket returns.
{"type":"ping"} receives {"type":"pong"} for connection health checks.
Remote UI¶
Routes use readable slugs such as #camera/rtsp-onvif-streaming. Older
bookmarks containing section titles still open and are replaced with the
canonical route. Plugin routes retain their unique plugin IDs.
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, showing Now Playing, the intercom or a camera view, taken again
a second after any of those changes, 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, and CPU, memory and
temperature tiles with the last fifteen minutes as a stack of cells per
sample, colored by height band and labelled with the scale's ceiling and
floor (the device samples every fifteen seconds whether or not a page is
open, getStatsHistory, and the stats push carries memFree and
memTotal beside the CPU load and temperature); 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.