Muralis

Reference

Remote control

Everything a Muralis panel answers to from a distance: the HTTP endpoints, every command, every setting you can write remotely, the status document field by field, and the MQTT side. The examples use 192.0.2.42 as the panel’s address; put your own in its place.

Before you start

The web admin and MQTT are Muralis Pro. Everything else on this page describes what those two surfaces do once Pro is active.

The web admin starts once an admin password of at least 8 characters is stored and the Web admin switch is on. Default port 8080; any port from 1024 to 65535. MQTT starts as soon as a broker host is stored; username and password are optional, the connection is plain TCP without TLS, so use it on a network you trust.

One command set. HTTP and MQTT hand every command to the same code, so a command behaves the same and is refused with the same words on both. After an accepted command the panel republishes its MQTT state within half a second, so a Home Assistant control does not snap back.

Device owner. Three things need the device-owner install (by QR at setup, or dpm set-device-owner over adb): system.reboot, the real screen-off, and CPU and temperature readings on stock Android. Everything else works on an ordinary install.

HTTP

HTTPS and the certificate

The panel serves HTTPS with a certificate it made itself. No authority signed it, so a browser warns once, about the authority and about the name, and one click accepts both. Compare what the browser shows with the fingerprint the panel prints on its web-admin card, on the web admin page itself, and in config.http_certificate_sha256. A plain http:// request on the same port is answered with a redirect to the https address and is never asked for a password. In the examples on this page -k tells curl to accept the certificate; to pin it instead, save it once with openssl s_client -connect 192.0.2.42:8080 -showcerts and pass it with --cacert.

curl -i http://192.0.2.42:8080/api/stats

Reply

HTTP/1.1 301 Moved Permanently
Location: https://192.0.2.42:8080/

This panel speaks HTTPS: https://192.0.2.42:8080/

Authentication

HTTP Basic on every request, the page included. The username is ignored; only the password counts, compared in constant time. No cookies, no tokens, no sessions.

curl -k -i https://192.0.2.42:8080/api/stats

Reply

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Muralis"
Content-Type: text/plain

Unauthorized

Five wrong passwords from one address lock that address out: 429 Too Many Requests with a Retry-After header, 30 seconds the first time, doubling each round up to 15 minutes, forgotten after an hour of quiet. Every lockout is counted in the status document under runtime.auth_lockouts, with the address that caused it.

From a browser

Every POST is checked against cross-site requests before it is routed. If the request carries Sec-Fetch-Site it must be same-origin or none; if it carries Origin it must match the Host the request went to. A request with neither header, which is what curl and automations send, passes. A refusal is 403 with a one-line reason.

curl -k -u :your-admin-password -H 'Origin: http://evil.example' -X POST https://192.0.2.42:8080/api/command -d cmnd=kiosk.reload

Reply, HTTP 403

Forbidden: Origin does not match Host

Endpoints

MethodPathWhat it doesReplies
GET/The web admin page.200 HTML
POST/Saves one settings box. Settings boxes.The page again, 200 saved or 400 refused with the reason on it
POST/api/commandRuns a command. Commands.200 JSON; 400 when the request itself is malformed
GET/api/commandRefused: a GET must not change state.405, Allow: POST
GET/api/statsThe status document. Status document.200 JSON
POST/api/settingApplies one instant setting. Instant settings.200 or 400 JSON
POST/api/checkTests a value from the panel’s side. Checks.200 JSON, the verdict inside
GET/privacy, /termsThe legal texts the app carries.200 HTML
GET/sensors, /sensor?id=, /automations, /automation?id=, /stats, /screensaverThe web admin’s pages below the settings: the sensors and one sensor, the automations and one rule’s editor, the stats and log, the screensaver.200 HTML
POST/api/automations/save, /api/automations/deleteThe editor’s Save and Delete, form-encoded: id (empty for a new rule), name, sensor, event_<sensor>, level, minutes, tag, action, argument, only, only_from, only_to, and baseline, the stored rule as JSON (see the stale rule under settings boxes).Save: 303 to /automations?id=; Delete: 303 to /automations; the editor again with the reason when refused
GET/api/automations, /api/sensors/settings, /api/beacons/namesEvery automation, every sensor setting, every beacon name, each as one JSON document. Automations and sensor settings.200 JSON
POST/api/automations, /api/sensors/settings, /api/beacons/namesWrites the same documents back, application/json.200 with the document as stored; 400 JSON with the reason, nothing written
GET/api/logThe app’s own log lines, plain text, the newest 200 at most. ?level= one of V D I W E F, ?own=1 for Muralis’s tags alone, ?q= to keep the lines holding that text.200 text
GET/camera/stream, /camera/snapshot.jpgThe camera as an IP camera while its sensor is on: an MJPEG stream (multipart/x-mixed-replace, one part per frame) and the last frame as one JPEG. Two streams at a time; a stream ends when the camera stops, sends no frame for 15 seconds, or one frame takes longer than 10 seconds to be read.200; 503 while the camera is off or has no frame yet, or with Retry-After when two streams are already open
anyanything else404
curl -k -i -u :your-admin-password 'https://192.0.2.42:8080/api/command?cmnd=kiosk.reload'

Reply

HTTP/1.1 405 Method Not Allowed
Allow: POST
Content-Type: application/json

{"status":"rejected","detail":"use POST; GET cannot change state"}

Limits

Request body16 384 bytes; 163 840 for the three documents under Automations and sensor settings. A larger body is refused with 413, naming the size and the limit.
Request line4096 bytes, then 400.
Time8 seconds for the whole request.
ConnectionsOne request per connection (Connection: close on every reply), 6 at a time per address, 8 in total.

Commands

Sending one

Always a POST to /api/command. Three encodings are accepted, and they mean the same thing.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=display.brightness -d percent=40
curl -k -u :your-admin-password -X POST 'https://192.0.2.42:8080/api/command?cmnd=kiosk.reload'
curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -H 'Content-Type: application/json' \
     -d '{"command":"display.orientation","args":{"value":"auto"}}'

Arguments are url, percent, value and enabled, as form fields or inside args. enabled takes true/false, 1/0 or on/off.

Replies

Every reply is JSON with two fields, status and detail.

statusMeaningHTTP code
acceptedDone, or handed to the screen. detail is usually empty.200
rejectedA bad argument, or something the panel cannot do right now. detail says why, in one sentence.200 when the command was understood and refused; 400 when the request itself was broken (missing command, malformed json, enabled must be true or false)
unsupportedUnknown command, or system.reboot without device owner.200

Read status, not the HTTP code. A command that was understood and refused is still HTTP 200. Only a request the server could not parse is 400.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command -d cmnd=kiosk.dance

Reply

{"status":"unsupported","detail":"unknown command"}

The page

kiosk.set_url

Stores url as the panel’s page and shows it. http:// or https://, up to 2048 characters.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=kiosk.set_url -d url=https://grafana.example.net/d/wall

Reply

{"status":"accepted","detail":""}

Refused with url must not be empty, url is too long or url must start with http:// or https://.

kiosk.open_url

Shows url now without storing it. A restart, a reboot or the nightly clean returns to the stored page. Same rules as kiosk.set_url.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=kiosk.open_url -d url=https://example.com/

Reply

{"status":"accepted","detail":""}

Afterwards the status document shows runtime.last_page_url as https://example.com/ while config.dashboard_url is unchanged.

kiosk.home

Back to the stored page.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command -d cmnd=kiosk.home

Reply

{"status":"accepted","detail":""}

kiosk.reload

Reloads the page that is on the screen.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command -d cmnd=kiosk.reload

Reply

{"status":"accepted","detail":""}

kiosk.restart

Shows the stored page again from scratch: a fresh web view, not just a reload.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command -d cmnd=kiosk.restart

Reply

{"status":"accepted","detail":""}

kiosk.stop

kiosk.start

kiosk.stop blanks the panel and stops loading anything until kiosk.start or a new page arrives. Both survive the nightly restart.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command -d cmnd=kiosk.stop

Reply

{"status":"accepted","detail":""}

The screensaver

Every screensaver setting is a command, and every one is also an entity over MQTT discovery, so a caller can publish a command or move a control, whichever suits. A changed setting is saved and republished at once; it takes effect the next time the screensaver starts.

screensaver.start

Shows the screensaver now, in the mode that is set, without waiting for the idle time. A touch on the glass ends it as usual.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.start

Reply

{"status":"accepted","detail":""}

Refused with the reason when it cannot show: the screensaver mode is off, the kiosk is stopped, Muralis is not on screen, the first-start wizard is on screen, or the escape combination recorder is on screen. With the panel’s settings open it shows as a preview, and a tap on the glass brings the settings back.

screensaver.stop

Ends a showing screensaver and brings the page back. Accepted even when none is showing.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.stop

Reply

{"status":"accepted","detail":""}

screensaver.mode

What the screensaver is: off, dim (the page, dimmed), film (a black film over the page), url (another web page) or pictures.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.mode -d value=pictures

Reply

{"status":"accepted","detail":""}

Refused with value must be off, dim, film, url or pictures.

screensaver.idle_seconds

Seconds without a touch before the screensaver starts. 0 means only when asked for, with screensaver.start.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.idle_seconds -d value=120

Reply

{"status":"accepted","detail":""}

Refused with value must be a whole number of seconds, 0 to 86400.

screensaver.off_seconds

Seconds of screensaver before the display is turned off, the way display.visual_off does it. 0 means never.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.off_seconds -d value=900

Reply

{"status":"accepted","detail":""}

Refused with value must be a whole number of seconds, 0 to 86400.

screensaver.dim_percent

The brightness of the dimmed page, for the dim mode.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.dim_percent -d value=20

Reply

{"status":"accepted","detail":""}

Refused with value must be a whole number from 1 to 100.

screensaver.url

The page the url mode shows, checked like the dashboard address; empty forgets it.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.url -d url=https://example.org/clock

Reply

{"status":"accepted","detail":""}

Refused with the same reasons as kiosk.set_url.

screensaver.on_wake

What a wake from display off shows first: screensaver, a touch then opens the page, or dashboard, the page at once.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.on_wake -d value=screensaver

Reply

{"status":"accepted","detail":""}

Refused with value must be screensaver or dashboard.

screensaver.source

Where the pictures mode takes its pictures: local, the panel’s own folders and uploads in the playlist in use, bing, Bing’s image of the day, or wikimedia, Wikimedia Commons’ picture of the day. The two online sources are fetched over the internet and always shown with their credit line.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.source -d value=local

Reply

{"status":"accepted","detail":""}

Refused with value must be local, bing or wikimedia.

screensaver.playlist

The playlist the local source shows, by name; an empty value puts none in use. Playlists are made and filled on the panel or in the web admin.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.playlist -d value=Holidays

Reply

{"status":"accepted","detail":""}

Refused when no playlist has that name.

screensaver.picture_seconds

How long each picture stays.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.picture_seconds -d value=20

Reply

{"status":"accepted","detail":""}

Refused with value must be a whole number of seconds, 1 to 86400.

screensaver.transition

How one picture gives way to the next: none (a cut), fade or slide.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.transition -d value=fade

Reply

{"status":"accepted","detail":""}

Refused with value must be none, fade or slide.

screensaver.picture_fit

How a picture is laid on the screen, one setting for all of them: fit shows the whole picture with bars where its shape differs from the screen’s, fill covers the screen and crops the edges, stretch covers the screen and pulls the picture out of shape, actual shows it at its own size, centred.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.picture_fit -d value=fit

Reply

{"status":"accepted","detail":""}

Refused with value must be fit, fill, stretch or actual.

screensaver.credit_corner

The corner the title and credit line sit in: bottom_left, bottom_right, top_left or top_right.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.credit_corner -d value=bottom_left

Reply

{"status":"accepted","detail":""}

Refused with value must be bottom_left, bottom_right, top_left or top_right.

screensaver.shuffle

Shows the playlist in a random order rather than its own.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=screensaver.shuffle -d enabled=true

Reply

{"status":"accepted","detail":""}

Refused with enabled must be true or false. The same shape for screensaver.one_per_cycle, one picture per screensaver instead of a slideshow, and screensaver.credit, the title and credit line, which the two online sources show whatever this says because their licences require it.

The display

display.wake

Wakes the screen: ends a sleep, lifts the black film, restores the brightness.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command -d cmnd=display.wake

Reply

{"status":"accepted","detail":""}

display.visual_off

Darkens the panel the way the display-off method decides: a real screen-off on a device-owner install that can be trusted to sleep, or a black film at minimum brightness. Survives the nightly restart, not a reboot.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command -d cmnd=display.visual_off

Reply

{"status":"accepted","detail":""}

The status document then shows display.source: "display_off", display.brightness_percent: 0, and under display.off_method_effective which of the two happened. display.screen_on is false after a sleep and stays true under the film, since the screen is still awake behind it.

display.brightness

Sets the brightness, percent from 0 to 100. Needs the one grant Android keeps for a person, explained on the setup page; does nothing while automatic brightness is on.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=display.brightness -d percent=40

Reply

{"status":"accepted","detail":""}

With percent=150

{"status":"rejected","detail":"percent must be between 0 and 100"}

Also refused with brightness needs the WRITE_SETTINGS permission, which has not been granted, automatic brightness is on; turn it off to set a level, or the system refused the brightness write.

display.auto_brightness

Hands the brightness to the ambient light sensor with enabled=true, or takes it back.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=display.auto_brightness -d enabled=true

Reply on a tablet without a light sensor

{"status":"rejected","detail":"this device has no ambient light sensor"}

display.orientation

value is auto, landscape or portrait. auto follows the accelerometer; a fixed value renders the right way up even when the panel hangs upside down. Stored.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -H 'Content-Type: application/json' \
     -d '{"command":"display.orientation","args":{"value":"landscape"}}'

Reply

{"status":"accepted","detail":""}

Refused with value must be auto, landscape or portrait, or this device has no accelerometer to follow for auto on a tablet without one.

display.off_method

How display.visual_off darkens the panel: auto, sleep or film. auto sleeps where that can be trusted (device owner, on mains or exempt from battery optimisation, no earlier sleep that ended badly) and shows the film otherwise. Setting the method, to any value, also forgets a recorded bad sleep.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=display.off_method -d value=auto

Reply

{"status":"accepted","detail":""}

Refused with value must be auto, sleep or film, or a real screen-off needs the device-owner install for sleep on an ordinary install.

The sensors

The panel’s sensors (see the sensors block of the status document) and its own automations. A sensor this device lacks, or one still waiting for a permission on the panel, refuses with the reason.

sensor.enabled

Switches one sensor on or off: value names it (proximity, light, movement, bluetooth, nfc, audio, camera, microphone, pressure, temperature, humidity), enabled says which way. The panel’s own values (display, screensaver, battery, power, network, memory, processor) have a switch too and start on.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=sensor.enabled -d value=proximity -d enabled=1

Reply

{"status":"accepted","detail":""}

Refused with enabled must be true or false, value must name a sensor, no sensor is called …, not on this device: … or allow … on the panel first.

automation.enabled

Pauses or resumes one automation: value is the rule’s id from the automations array, enabled says which way. Rules are made on the panel or the web admin, or posted whole to /api/automations.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=automation.enabled -d value=wake -d enabled=0

Refused with no automation is called ….

camera.motion

camera.snapshot

camera.motion with enabled switches the camera’s motion detection. camera.snapshot publishes the camera’s last frame on kiosk/<id>/camera now, only while the camera’s camera_mqtt setting (Picture to MQTT) is on; refused with the camera is off, pictures to MQTT are off, the camera has not delivered a picture yet, MQTT is not configured or the broker’s state when nothing could be published.

proximity.calibrate

The proximity sensor’s calibration in steps, for a device whose driver never moves Android’s distance: value=covered with a hand over the sensor, then value=clear with nothing in front of it; the panel keeps the value that changed the most and reads near from it. value=reset returns to Android’s rule. Refused with cover the sensor first, the sensor has not reported in the last seconds or the sensor read the same covered and clear.

microphone.calibrate

The microphone’s calibration in steps, so its 0 to 100 runs from this room’s quiet to its loud: value=quiet with the room as quiet as it gets, then value=loud during the loudest sound that should read 100; each step averages the last three seconds. Until then the level spans the whole range of 16-bit audio. value=reset returns to it. Refused with the microphone is off, let it hear the quiet room first, the microphone has not heard anything in the last seconds or the quiet room and the loud sound were too alike (less than 10 dB apart).

sensor.sleep_test

Puts the panel to sleep for twenty seconds and notes whether the sensor named by value reported a changed reading meanwhile; some devices stop every sensor an app holds once the display sleeps. The sensor is asked again three seconds in and only a change after that counts, so someone has to change what it measures: a hand over the proximity or light sensor, a tap on the panel for movement. The verdict is asleep in the sensor’s block, reports or silent, which both surfaces show as Running and Not running. Refused with no sensor is called …, not on this device: …, only the panel’s own sensors can be tested, … is off, a test is already running, the display did not go off, or this panel darkens with the black film, which keeps its sensors awake where Display off is the film (only the panel’s own sensors can be tested is the answer for the camera, the microphone and the beacons). A wake before the twenty seconds ends the test without a verdict.

Sound

audio.volume

Sets the media volume: percent, 0 to 100. Refused with percent must be between 0 and 100.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=audio.volume -d percent=40

audio.play

audio.stop

audio.play plays the sound at url, http:// or https://, through the panel’s speaker; audio.stop stops it. Refused with url is required or url must start with http:// or https://.

audio.say

Speaks value through the device’s text-to-speech engine. Refused with value must hold the sentence to say, the sentence is longer than the speech engine takes (4000 characters) or the text-to-speech engine is not ready.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=audio.say -d 'value=The door is open'

The panel

webadmin.enabled

Switches the web admin off or on without touching the stored password. Stored. A caller on the web admin itself hears accepted and then loses the connection, which is the point.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command \
     -d cmnd=webadmin.enabled -d enabled=true

Reply

{"status":"accepted","detail":""}

telemetry.publish

Publishes the MQTT state document now, without waiting for the next minute.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command -d cmnd=telemetry.publish

Reply with no broker reachable

{"status":"rejected","detail":"MQTT is not connected"}

Also MQTT is not configured when no broker host is stored. With a broker connected the reply is accepted.

system.reboot

Device owner

Reboots the tablet.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/command -d cmnd=system.reboot

Reply

{"status":"accepted","detail":"rebooting"}

On an ordinary install

{"status":"unsupported","detail":"reboot needs device-owner status; provision Muralis as device owner"}

There is no system.shutdown: Android has no API an app could use to power a tablet off. It answers unsupported like any unknown command.

Settings

The stats overlay, the display-off method and every screensaver setting apply instantly through /api/setting; the web admin posts them the same way. The rest are saved the way the web admin saves them, one box at a time, through a POST to /. Orientation, automatic brightness and the page itself are set with the commands above.

Instant settings

Form-encoded, section=behaviour, one key per request. The panel republishes its MQTT state afterwards.

KeyValuesEffect
device_idthe panel’s name, letters, digits, dot, underscore and hyphen, 64 at mostSame as the panel box, without its baseline: the web admin stores the name this way the moment its box loses the focus. Refused with Not saved: the name ….
stats_overlay1/0, true/false, on/offDraws or hides the live stats block on the panel.
display_off_methodauto, sleep, filmSame as the command display.off_method. An ordinary install has the film alone.
screensaver_modeoff, dim, film, url, picturesSame as screensaver.mode.
screensaver_idle_s, screensaver_off_s, screensaver_picture_swhole secondsSame as screensaver.idle_seconds, screensaver.off_seconds and screensaver.picture_seconds.
screensaver_dim_percent1 to 100Same as screensaver.dim_percent.
screensaver_urlan address, or emptySame as screensaver.url.
screensaver_on_wake, screensaver_source, screensaver_transition, screensaver_picture_fit, screensaver_credit_cornerthe same words as the commandsSame as the command of the same name.
screensaver_shuffle, screensaver_one_per_cycle, screensaver_credit1/0, true/false, on/offSame as the commands with enabled.
sensor_<id>on/off words as aboveSame as sensor.enabled. An unknown id is refused.
sensor_option_<key>as the sensor’s page offersOne setting of a sensor’s page: movement_sensitivity (light, normal, heavy; the pages show them as High, Normal and Low, a light push being the most sensitive), movement_still_s, beacons_reach_s, camera_still_s (seconds from 1), camera_name (up to 40), camera_lens (front, back), camera_size, camera_fps (the offered ones), camera_orientation (device upright as the panel is turned, portrait, landscape: always tall or wide, the middle of the picture when the panel is the other way), camera_mirror, camera_flip, camera_watermark, camera_motion, camera_sensitivity (low, normal, high), camera_mqtt, beacon_<uuid:major:minor> (a beacon’s name, up to 40). Anything else is refused with the rule.
automation_<id>on/off wordsSame as automation.enabled.
curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/setting \
     -d section=behaviour -d stats_overlay=1

Reply

{"status":"accepted","detail":"saved"}

With display_off_method=dark, HTTP 400

{"status":"rejected","detail":"Display off method must be auto, sleep or film."}

Any other section is refused with not an instantly applied setting.

Settings boxes

Each box on the web admin posts its own fields to / together with a baseline: the values that were current when the page loaded, joined with |. A baseline that no longer matches refuses the save, so two people editing the same panel cannot overwrite each other without noticing. A script has to supply the baseline too; read the current values from /api/stats first. The reply is the page itself, 200 when saved, 400 with the reason when refused.

sectionFieldsBaselineRefused when
dashboarddashboard_urlbaseline = urlThe URL does not start with http:// or https://.
paneldevice_id, the panel’s namebaseline = idThe name is empty or uses anything but letters, digits, dot, underscore and hyphen, or is longer than 64. With scripting, the web admin stores the name the moment its box loses the focus, through device_id under instant settings.
mqttmqtt_host, mqtt_port, mqtt_username, mqtt_password (blank keeps the stored one)baseline = host|port|usernameThe port is not between 1 and 65535.
webadminhttp_port, http_admin_password (blank keeps)baseline = portThe port is not a number, not between 1024 and 65535, or already in use; the password is shorter than 8 characters, because a shorter one would switch the page off with only the tablet able to switch it back on.
sequencessettings_sequence, launcher_sequencesequence_baseline = settings|launcherBoth fields are empty, or both hold the same combination. Format TL,TR,BL,BR, 3 to 12 taps.
behaviourstats_overlay, display_off_method, every screensaver_* keynoneAs under instant settings.
curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/ \
     -d section=dashboard \
     -d dashboard_url=https://grafana.example.net/d/wall \
     -d baseline=https://old.example.net/

Reply when the baseline is stale, HTTP 400, on the page

Not saved: these settings were changed elsewhere after this page loaded. Reload and try again.

Automations and sensor settings

Three documents hold what you set on the Sensors and Automations pages. GET one, change it, POST it back as application/json. Every entry says when it last changed, in changed_at (milliseconds, 0 when it has not changed since the panel started keeping the time), and a removed rule or beacon name leaves a marker in deleted with its deleted_at, the newest 100 kept. The panel writes those times itself: the copies in a POST are ignored. A POST is checked in full before anything is written, so a refusal leaves everything as it was.

PathDocumentA POST
/api/automations{"rules": [...], "deleted": [...]}, each rule with the fields of automations in the status document without sentence.Replaces the whole list: a rule left out is deleted. Each rule is checked as the editor checks it (the rule’s words); a rule without an id gets a new one, and an id is lowercase letters and digits, 24 at most. At most 64 rules.
/api/sensors/settings{"switches": {"<id>": {"on", "changed_at"}}, "shared": {"<key>": {"value", "changed_at"}}, "this_panel": {...}}. shared holds the settings that mean the same on any panel, this_panel the ones that depend on the panel’s own hardware: the camera’s name, lens, size, orientation, mirror and flip, the calibrations, the sleep tests and the tags it has read.Sets the switches and shared settings it names, each checked as the sensor’s page checks it; the rest stay. this_panel is ignored, set those on the panel; a this_panel key under shared is refused, and so is a beacon_ key, which belongs to /api/beacons/names. switches lists every sensor as it stands, the panel’s own readings on with nothing stored.
/api/beacons/names{"names": [{"id", "name", "changed_at"}], "deleted": [...]}, the names given to beacons heard, id as uuid:major:minor.Sets the names it lists, up to 40 characters; a blank name takes it away. A beacon left out keeps its name.

A rule

sensor and event name what the rule waits for, action what it does; level goes with an event that compares, minutes (0 to 1440, 0 for at once) with one that must hold, tag (64 characters at most) with the NFC event, argument (500 at most) with an action that needs one. name is 60 characters at most and, left blank, becomes the rule in words. A rule’s condition is read again from the start when it is edited, so a rule whose condition already holds when saved does not run on the spot.

sensorevent
proximitynear, far
lightdarker (level in lx, holds for minutes), brighter (level)
movementpicked_up, still
bluetoothin_reach, out_of_reach
nfctag (tag names which, empty for any)
audioplaying, stopped
cameramotion, no_motion (holds for minutes)
microphonelouder (level, 0 to 100)
displayon, off
screensaverstarted, ended
batterybelow (level in percent), plugged, unplugged
networkconnected, lost
processorhotter (level in °C)

action: display_on, display_off, dashboard, screensaver, play_sound (argument an http:// or https:// address), say (argument the sentence), reload. The editor’s baseline is the stored rule without enabled and changed_at, so a switch flipped beside an open editor never makes its Save stale.

curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/sensors/settings \
     -H 'Content-Type: application/json' \
     -d '{"switches": {"microphone": {"on": true}}}'

With {"shared": {"camera_size": {"value": "640x480"}}}, HTTP 400

{"status":"rejected","detail":"camera_size belongs to this panel alone"}

Checks

POST /api/check tests a value from the panel’s own point of view before you save it: does that URL answer, is that port free, does that broker accept a connection. It is advice, not a gate; the save paths keep their own refusals. Always 200, the verdict is inside.

kindFieldsok whendetail
dashboard_urlvalueThe URL answers HTTP, whatever the code.answered HTTP 200; no HTTP answer plus a reason
http_portvalueThe port is free, or is the one the admin serves on.port 8081 is free; the web admin is serving on it right now; port 80 is already in use on this device; web admin port must be between 1024 and 65535
mqtt_hostvalue, optional port (1883)A TCP connection succeeds within 3 seconds.accepts TCP on port 1883; no TCP answer on port 1883 plus a reason
curl -k -u :your-admin-password -X POST https://192.0.2.42:8080/api/check \
     -d kind=mqtt_host -d value=192.0.2.10 -d port=1883

Reply, broker not running

{"ok":false,"detail":"no TCP answer on port 1883","reason":"failed to connect to /192.0.2.10 (port 1883) from /192.0.2.42 (port 37568) after 3000ms: isConnected failed: ECONNREFUSED"}

Status document

GET /api/stats returns it; the MQTT state topic carries the same document without the five lines marked Admin. Times are milliseconds. A value the tablet cannot provide is null, never missing and never zero; inside sensors, a sensor’s reading fields are there only while it is on.

{
  "schema": 1,
  "app_version": "0.6.0",
  "uptime_ms": 79535237,
  "app_uptime_ms": 28207013,
  "battery": {"present": true, "percent": 84, "status": 2, "health": 2, "plugged": 2, "charge_state": "charging",
              "temperature_c": 29.3, "voltage_mv": 4022, "charge_counter_uah": 3126890,
              "current_now_ua": 119018, "current_average_ua": null, "energy_counter_nwh": null},
  "power": {"source": "mains", "volts": null, "watts": null},
  "memory": {"available_bytes": 862355456, "total_bytes": 1954566144, "low": false,
             "threshold_bytes": 226492416, "process_pss_kib": 125244},
  "storage": {"available_bytes": 6103379968, "total_bytes": 7761584128},
  "network": {"connected": true, "ip_address": "192.0.2.42", "metered": false, "validated": true,
              "wifi": true, "ethernet": false, "wifi_rssi_dbm": -78, "wifi_signal_level": 1,
              "wifi_link_speed_mbps": 21, "wifi_frequency_mhz": 2437},
  "thermal_status": 0,
  "load_average": [3.16, 3.29, 3.12],
  "system": {"cpu_busy_percent": 3.9, "cpu_max_frequency_khz": 960000, "mem_total_kb": 1908756,
             "mem_available_kb": 854296, "mem_used_kb": 1054460, "swap_total_kb": 1048572,
             "swap_used_kb": 403992, "cpu_temperature_c": 40, "gpu_temperature_c": 39,
             "load_average": [3.16, 3.29, 3.12]},
  "runtime": {"renderer_deaths": 0, "last_renderer_death_ago_ms": null,
              "last_page_finished_ago_ms": 366490, "last_page_error_ago_ms": 1013827,
              "last_page_error": "HTTP 404", "last_page_url": "https://grafana.example.net/d/wall",
              "auth_lockouts": 0, "last_auth_lockout_ago_ms": null, "last_auth_lockout_host": "",
              "recycles": 0, "last_recycle_ago_ms": null, "last_recycle_reason": ""},
  "display": {"screen_on": true, "off_method_effective": "sleep",
              "off_method_reason": "Display off turns the screen off. A remote wake or the power button turns it back on.",
              "off_method_warning": false, "auto": false, "has_light_sensor": false,
              "source": "manual", "brightness_percent": 40, "system_raw": 102, "system_scale_assumed": 255},
  "screensaver": {"active": false, "mode": "pictures", "idle_s": 120, "off_s": 900, "dim_percent": 20,
                  "on_wake": "screensaver", "url": "", "source": "local", "picture_s": 20, "transition": "fade",
                  "picture_fit": "fit", "shuffle": false, "one_per_cycle": false, "credit": true,
                  "credit_corner": "bottom_left", "source_state": "6 pictures from Holidays.", "source_problem": null,
                  "pictures_readable": true, "playlist": "Holidays", "playlists": 2,
                  "picture": {"title": "The Starry Night", "credit": "Vincent van Gogh, 1889"}, "problem": null,
                  "summary": "Pictures from this panel after 2 min without a touch, display off 15 min later."},
  "config": {"dashboard_url": "https://grafana.example.net/d/wall", "device_id": "hallway-panel",
             "settings_sequence": "TL,TL,TL,TL", "launcher_sequence": "BR,BR,BR,BR",
             "stats_overlay": true, "orientation": "auto", "display_off_method": "auto",
             "has_light_sensor": false, "auto_brightness": false, "web_admin_enabled": true,
             "http_port": 8080, "http_tls": true,
             "http_certificate_sha256": "F0:4D:30:A1:3D:67:35:08:11:C8:93:F4:28:EA:C3:69:D3:D9:BA:77:3B:DB:97:31:3B:B7:AB:C2:A7:50:7D:63",
             "mqtt_host": "192.0.2.10", "mqtt_port": 1883}
}

A real document, with the addresses, the page and the combinations replaced. On a panel with no battery, a PoE wall panel or a screen on a DC adapter, battery.present is false, every other battery field is null, and power.source says mains.

FieldMeaningValues
Top level
schemaDocument version.1
app_versionThe running build.string
uptime_msSince the tablet booted.ms
app_uptime_msSince Muralis started; drops at the nightly restart.ms
thermal_statusAndroid’s thermal status.0 none, 1 light, 2 moderate, 3 severe, 4 critical, 5 emergency, 6 shutdown; null below Android 10
load_average1, 5 and 15 minute load.three numbers, or null
battery
presentWhether the panel has a battery at all. When false, every other field here is null except plugged.bool
percentLevel.0 to 100
statusAndroid’s battery status.1 unknown, 2 charging, 3 discharging, 4 not charging, 5 full
healthAndroid’s battery health.2 good, 3 overheat, 4 dead, 5 over voltage, 6 failure, 7 cold
pluggedWhat it is plugged into.0 nothing, 1 AC, 2 USB, 4 wireless
charge_stateThe word the overlay and Home Assistant show.charging, discharging, charged, on hold, null
temperature_c, voltage_mvBattery temperature and voltage.°C, mV
charge_counter_uah, current_now_ua, current_average_ua, energy_counter_nwhBattery counters where the hardware reports them.µAh, µA, µA, nWh, or null
power
sourceHow the panel is fed, on every panel: a cell with no cable, a cell on an induction pad, or mains, which is a charger, PoE and a DC adapter alike. Android cannot tell those three apart; network.ethernet says whether a cable carries the data too, and the battery object says whether a cell is filling.battery, wireless, mains
volts, wattsLive readings of the supply feeding the device, where the kernel measures them (/sys/class/power_supply, an online non-battery node, never a rating). Most tablets report none. What is reported is whatever the kernel measures at the device’s own DC input, 5 V from USB up to 12 or 24 V from a DC or PoE feed; the 230 V on the wall is never visible to any device. The overlay shows them on the MAINS row of a panel without a battery.number, or null
memory, storage
memory.available_bytes, total_bytes, threshold_bytesSystem memory, and the level below which Android calls it low.bytes
memory.lowAndroid’s low-memory flag. Published at once when it changes.bool
memory.process_pss_kibMemory used by Muralis itself.KiB
storage.available_bytes, total_bytesThe data partition.bytes, or null
network
connected, validated, metered, wifi, ethernetThe active network.bool
ip_addressThe panel’s own address.string
wifi_rssi_dbm, wifi_signal_level, wifi_link_speed_mbps, wifi_frequency_mhzWi-Fi details, on Wi-Fi only.dBm; 0 to 4; Mbps; MHz
system
cpu_busy_percentCPU busy since the last sample.%; null on an ordinary install on stock Android
cpu_max_frequency_khzThe current top CPU clock.kHz
mem_total_kb, mem_available_kb, mem_used_kb, swap_total_kb, swap_used_kbFrom /proc/meminfo.KiB
cpu_temperature_c, gpu_temperature_cChip temperatures, same availability as cpu_busy_percent.°C, or null
runtime
renderer_deaths, last_renderer_death_ago_msWeb view renderer crashes since start.count, ms
last_page_finished_ago_ms, last_page_error_ago_ms, last_page_errorPage health: last successful load, last error, and what it was.ms, ms, string such as HTTP 404
last_page_urlThe page actually on the glass, which kiosk.open_url changes.string
auth_lockouts, last_auth_lockout_ago_ms, last_auth_lockout_hostWeb admin lockouts after repeated wrong passwords.count, ms, address
recycles, last_recycle_ago_ms, last_recycle_reasonSelf-healing rebuilds of the web view.count, ms, string
display
screen_onWhether the screen is awake.bool
off_method_effectiveWhat display.visual_off would do now.sleep, film
off_method_reasonOne sentence saying why.string
off_method_warningA sleep ended badly and the method was switched to the film. Painted red on both admin surfaces.bool
auto, has_light_sensorAutomatic brightness on, and whether a light sensor exists.bool
sourceWhere the brightness comes from.auto, manual, display_off
brightness_percentThe level.0 to 100, and 0 while dark; null only when the system setting cannot be read
system_raw, system_scale_assumedAndroid’s raw brightness value and the scale assumed for it, so a wrong percentage can be diagnosed.int, 255
screensaver
activeWhether the screensaver is showing right now.boolean
mode … credit_cornerThe settings, one field per command above, under the command’s name without the prefix: idle_s, off_s and picture_s are the three times.as the commands
source_stateOne sentence about the source: how many pictures, from where, and what is showing.string
source_problemWhy the source has nothing to show, when it has not.string or null
pictures_readableWhether the panel may read its own pictures yet.boolean
playlist, playlistsThe playlist in use, and how many there are.string or null, number
pictureThe picture on screen: its title and its credit line.{"title","credit"} or null
problem, summaryWhy the screensaver cannot run as set, and the settings in one sentence, the same sentence both surfaces show.string or null, string
sensors
one object per sensor, by idEvery sensor the app knows, on this device or not: name, kind (switch, permission, panel), page, glyph, available, permitted, enabled, active (on and permitted), value, attributes where the sensor has them (the beacons in reach, the camera’s name, viewers and detection, the last tag’s content, the audio state), and reading, the sentence both surfaces show.object
near, calibratedThe proximity sensor’s verdict and whether a person calibrated it; calibrated on the microphone too.bool
asleepWhat the test while asleep found for a hardware sensor.reports, silent, or empty
lists
sensors_home, sensors_page, automations_home, automations_pageShort hashes of what each list of the web page is drawn from, so the page draws a list again when another surface changed it. Compare them, never read them. Admin: HTTP only, never on MQTT.strings
automations
one object per ruleid, name, sensor, event, level (absent where the event has none), minutes, tag, action, argument, enabled, only_from, only_to (minutes of the day, -1 for the whole day), changed_at and sentence, the rule in words.array
config
dashboard_url, device_idThe stored page and the panel’s id, which is also its MQTT client id.string
settings_sequence, launcher_sequenceThe corner-tap combinations. Admin: HTTP only, never on MQTT.TL,TR,BL,BR strings
escape_pin_setWhether a PIN is asked after a combination; the PIN itself is never reported. Admin: HTTP only.bool
stats_overlay, orientation, display_off_method, web_admin_enabledStored settings; display_off_method reads film on an ordinary install whatever is stored, since the film is the only method it has.bool; auto/landscape/portrait; auto/sleep/film; bool
has_light_sensor, auto_brightnessLive, repeated here for the Home Assistant switch.bool
http_port, mqtt_host, mqtt_portWhere the panel listens and which broker it talks to. Admin: HTTP only.
http_tls, http_certificate_sha256Whether the web admin speaks HTTPS, and the fingerprint of its certificate, to compare with the browser’s. Admin: HTTP only.bool, string

MQTT

<id> below is the panel’s device id, hallway-panel in the examples. Two panels on one broker need two different ids.

Connection

TransportMQTT 3.1.1 over plain TCP, default port 1883, no TLS. Clean session, keepalive 30 s.
Client idThe device id.
Last willkiosk/<id>/availability = offline, retained.
On connectPublishes online, subscribes to its command topic and to homeassistant/status, publishes discovery. When Home Assistant announces itself as back online, discovery is published again.
ReconnectAutomatic after a lost connection. A broker that was unreachable when the panel started is retried when the MQTT settings are saved or at the nightly restart.

Topics

TopicWhoPayloadWhen
kiosk/<id>/availabilitypanelonline or offline, retainedOn connect, then online again every 30 s as a heartbeat.
kiosk/<id>/statepanelThe status document without the admin lines, retainedEvery 60 s; at once when battery level, charging state, low memory or thermal status change; 400 ms after every accepted command; on reconnect.
kiosk/<id>/commandyouA bare command name, or a JSON envelopeWhenever you like. Not retained.
kiosk/<id>/command/resultpanel{"id","status","detail","timestamp_ms"}One per command received.
kiosk/<id>/tagpanel{"tag_id"}, QoS 1, not retainedA tag held to the panel while the NFC sensor is on; Home Assistant’s tag scanned event, through homeassistant/tag/<id>/config.
kiosk/<id>/camerapanelThe last frame as raw JPEG, retained; an empty payload clears itOn motion while the camera’s picture-to-MQTT option is on, and on camera.snapshot. Cleared when the camera or the option goes off.
homeassistant/device/<id>/configpanelThe discovery bundle, retainedOn connect, and when Home Assistant comes back.
homeassistant/binary_sensor/<id>_mqtt_state/configpanelThe liveness entity, retainedSame.

Commands over MQTT

The same commands and arguments as over HTTP. A bare name is enough for a command without arguments; the envelope carries arguments and an optional id of up to 96 characters, echoed in the result so you can match replies to requests.

mosquitto_pub -h 192.0.2.10 -t kiosk/hallway-panel/command -m kiosk.reload

On kiosk/hallway-panel/command/result

{"id":"","status":"accepted","detail":"","timestamp_ms":1788948053209}
mosquitto_pub -h 192.0.2.10 -t kiosk/hallway-panel/command \
    -m '{"id":"evening-1","command":"display.brightness","args":{"percent":40}}'

Result

{"id":"evening-1","status":"accepted","detail":"","timestamp_ms":1788948057237}

With "percent":150

{"id":"evening-1","status":"rejected","detail":"percent must be between 0 and 100","timestamp_ms":1788948061318}

With a payload that is not JSON and not a command name

{"id":"","status":"rejected","detail":"malformed JSON payload","timestamp_ms":1788948066121}

Two more rules. A payload over 16 384 bytes is refused with payload exceeds 16384 bytes. A command the broker had stored as retained and delivers when the panel connects is refused with retained commands are refused; publish without the retain flag, and the panel clears it from the broker: a retained command would otherwise run again on every reconnect.

Home Assistant discovery

One retained bundle on homeassistant/device/<id>/config creates the whole device: name Muralis <id>, manufacturer and model from the hardware, software version from the app. Every entity reads the state topic and sends to the command topic, so nothing has to be declared in YAML. An entity the panel cannot serve is not announced at all: no battery sensors without a battery, no automatic brightness without a light sensor, no auto orientation without an accelerometer, no reboot button and no sleep option without the device-owner install.

EntityTypeReadsSends
Power sourcesensor, enumpower.source
Battery levelsensor, %, only on a panel with a batterybattery.percent
Battery temperaturesensor, °C, only with a batterybattery.temperature_c
Battery statesensor, only with a batterybattery.charge_state
Available memorysensor, MiBmemory.available_bytes
Available storagesensor, MiBstorage.available_bytes
Thermal statussensor, Android 10 and upthermal_status
App versionsensor, diagnosticapp_version
Brightnessnumber, 0 to 100display.brightness_percentdisplay.brightness
Automatic brightnessswitch, only with a light sensorconfig.auto_brightnessdisplay.auto_brightness
Orientationselectconfig.orientationdisplay.orientation
Display off methodselect, auto and sleep offered to a device owner only; an ordinary install offers film aloneconfig.display_off_methoddisplay.off_method
Web adminswitchconfig.web_admin_enabledwebadmin.enabled
Dashboard URLtextconfig.dashboard_urlkiosk.set_url
Open URL oncetextruntime.last_page_urlkiosk.open_url
Main dashboard, Reload dashboard, Restart kioskbuttonskiosk.home, kiosk.reload, kiosk.restart
Display on, Display offbuttonsdisplay.wake, display.visual_off
Reboot tabletbutton, only on a device-owner installsystem.reboot
Screensaverswitchscreensaver.activescreensaver.start, screensaver.stop
Screensaver modeselectscreensaver.modescreensaver.mode
Screensaver playlistselect, the panel’s playlists by namescreensaver.playlistscreensaver.playlist
Screensaver sourceselectscreensaver.sourcescreensaver.source
Screensaver idle time, to display off, picture timenumber, sscreensaver.idle_s, off_s, picture_sscreensaver.idle_seconds, off_seconds, picture_seconds
Screensaver dim brightnessnumber, %screensaver.dim_percentscreensaver.dim_percent
Screensaver transition, picture fit, credit corner, after a wakeselectscreensaver.transition, picture_fit, credit_corner, on_wakethe command of the same name
Screensaver shuffle, one picture per cycle, credit lineswitchscreensaver.shuffle, one_per_cycle, creditthe command of the same name with enabled
Screensaver pagetextscreensaver.urlscreensaver.url
Screensaver picturesensorscreensaver.picture.title
Proximity, Light, Movement, Bluetooth beacons, NFC, Audio, Camera, Microphone, Pressure, Ambient temperature, Humiditysensor or binary sensor, one each, announced while the sensor is on and this device has it, withdrawn otherwisesensors.<id>.value, with attributes for the beacons, NFC, audio and the camera
Camera motion detection, Camera snapshot, Camera pictureswitch, button, camera, only while the camera is onsensors.camera.attributes.detecting; the picture from kiosk/<id>/cameracamera.motion, camera.snapshot
One switch per automation, named after the ruleswitchautomations[].enabledautomation.enabled

Liveness

A separate entity, MQTT state, is a connectivity sensor on the availability topic with a 90 second expiry. The panel says online every 30 seconds; 90 seconds of silence turn the sensor off, even if a broker restart brought back a stale retained online. A panel that goes quiet reads unavailable in Home Assistant, never a stale online.

Beyond the API

Device owner over adbadb shell dpm set-device-owner org.spazio17.muralis/.KioskDeviceAdminReceiver, on a tablet that has finished setup, has no accounts and one user. The QR at setup does the same without adb.
The brightness grantadb shell appops set org.spazio17.muralis WRITE_SETTINGS allow, or the button in Muralis settings. The one permission a device owner cannot give itself; the setup page explains why.
The provisioning QRCarries the admin component, the APK address and the signing certificate, and no Muralis settings. What it encodes is published in full.