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.
Reply
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.
Reply
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.
Reply, HTTP 403
Endpoints
Reply
Limits
Commands
Sending one
Always a POST to /api/command. Three encodings are accepted, and they mean the same thing.
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.
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.
Reply
The page
Stores url as the panel’s page and shows it. http:// or https://, up to 2048 characters.
Reply
Refused with url must not be empty, url is too long or url must start with http:// or https://.
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.
Reply
Afterwards the status document shows runtime.last_page_url as https://example.com/ while config.dashboard_url is unchanged.
Back to the stored page.
Reply
Reloads the page that is on the screen.
Reply
Shows the stored page again from scratch: a fresh web view, not just a reload.
Reply
kiosk.stop blanks the panel and stops loading anything until kiosk.start or a new page arrives. Both survive the nightly restart.
Reply
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.
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.
Reply
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.
Ends a showing screensaver and brings the page back. Accepted even when none is showing.
Reply
What the screensaver is: off, dim (the page, dimmed), film (a black film over the page), url (another web page) or pictures.
Reply
Refused with value must be off, dim, film, url or pictures.
Seconds without a touch before the screensaver starts. 0 means only when asked for, with screensaver.start.
Reply
Refused with value must be a whole number of seconds, 0 to 86400.
Seconds of screensaver before the display is turned off, the way display.visual_off does it. 0 means never.
Reply
Refused with value must be a whole number of seconds, 0 to 86400.
The brightness of the dimmed page, for the dim mode.
Reply
Refused with value must be a whole number from 1 to 100.
The page the url mode shows, checked like the dashboard address; empty forgets it.
Reply
Refused with the same reasons as kiosk.set_url.
What a wake from display off shows first: screensaver, a touch then opens the page, or dashboard, the page at once.
Reply
Refused with value must be screensaver or dashboard.
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.
Reply
Refused with value must be local, bing or wikimedia.
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.
Reply
Refused when no playlist has that name.
screensaver.picture_seconds
How long each picture stays.
Reply
Refused with value must be a whole number of seconds, 1 to 86400.
How one picture gives way to the next: none (a cut), fade or slide.
Reply
Refused with value must be none, fade or slide.
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.
Reply
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.
Reply
Refused with value must be bottom_left, bottom_right, top_left or top_right.
Shows the playlist in a random order rather than its own.
Reply
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
Wakes the screen: ends a sleep, lifts the black film, restores the brightness.
Reply
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.
Reply
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.
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.
Reply
With percent=150
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.
Hands the brightness to the ambient light sensor with enabled=true, or takes it back.
Reply on a tablet without a light sensor
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.
Reply
Refused with value must be auto, landscape or portrait, or this device has no accelerometer to follow for auto on a tablet without one.
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.
Reply
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.
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.
Reply
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.
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.
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.
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.
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).
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
Sets the media volume: percent, 0 to 100. Refused with percent must be between 0 and 100.
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://.
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.
The panel
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.
Reply
Publishes the MQTT state document now, without waiting for the next minute.
Reply with no broker reachable
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.
Reply
On an ordinary install
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.
Reply
With display_off_method=dark, HTTP 400
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.
Reply when the baseline is stale, HTTP 400, on the page
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.
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.
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.
With {"shared": {"camera_size": {"value": "640x480"}}}, HTTP 400
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.
Reply, broker not running
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.
MQTT
<id> below is the panel’s device id, hallway-panel in the examples. Two panels on one broker need two different ids.
Connection
Topics
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.
On kiosk/hallway-panel/command/result
Result
With "percent":150
With a payload that is not JSON and not a command name
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.
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