Home Assistant on the Sonoff NSPanel Pro 86, natively.
A Flutter app that renders the custom:nspanel-* cards from
NSPanel-cards without a browser in the way.
The web cards are careful with the panel's frame budget, but they are passengers: open a
dashboard in the companion app and the WebView is also running the whole HA frontend, and on a
PX30 that is most of the cost. This draws straight to the GPU instead.
New here? TUTORIAL.md walks from an empty Home Assistant to a working panel, and has the short recipe for adding the second, third and fourth one.
The app reads your Lovelace dashboard from Home Assistant over the websocket and renders
the nspanel-* cards in it. You keep configuring in HA - YAML or the GUI editor, which keeps
working - and the panel follows; edit the dashboard and it reloads on its own. The same
config still works in a browser with the web cards, on a phone or a tablet.
The layout it expects is the one the cards' README recommends: a panel view holding a swipe card whose children are the pages. A view with no swipe card becomes one page. Cards it does not know are shown as a marked gap rather than dropped, so you can see what a dashboard is asking for that this app cannot do.
views:
- type: panel
cards:
- type: custom:simple-swipe-card
cards:
- type: vertical-stack
cards:
- type: custom:nspanel-light-card
entity: light.dining_lights
title: Dining table
height: 260
- type: custom:nspanel-light-card
entity: light.lounge_lamp
title: Lounge lamp
height: 184
show_presets: false
- type: vertical-stack
cards:
- type: custom:nspanel-climate-card
entity: climate.living_room
height: 300
- type: custom:nspanel-sensors-card
height: 144
entities: [sensor.outside_temp, sensor.outside_hum, sensor.wind]Every card and option is documented in the cards repo's README; this app takes the same ones. Heights are in logical pixels, which on the panel at stock density are the panel's pixels.
The switch card is the button card's sibling for switches, input booleans and fans: each tile
is lit while its entity is on, and a tap turns it the other way, echoed at once for
echo_ms so the tile never shows the old state under a finger.
The alarm card is the one with a keypad: when the alarm wants a code, arming or disarming
opens one full screen, and a refused code says so and clears. It rings the built-in
armed, disarmed and alarm sounds as the state changes (sounds: false to stop it).
vertical-stack, horizontal-stack and grid are rendered as layout, nested as deep as you
like. In a horizontal stack every child is an equal column and keeps its own height, so give
them the same one. grid takes columns; square is ignored.
After a period without a touch the panel shows a photo and a clock that wanders so nothing burns in. Any touch wakes it, and so does someone walking up to it - the NSPanel Pro has a real proximity sensor and the app reads it.
Configure it with a card anywhere in the dashboard. It renders nothing (the web bundle ships a matching empty card, so Lovelace does not complain either); the app reads it and drops it:
- type: custom:nspanel-screensaver
after: 300 # seconds without a touch; default 300
image_url: https://fastly.picsum.photos/id/650/440/440.jpg
image_refresh: 600 # seconds between new pictures while idle; default 600
image_fit: contain # the whole picture, its own shape, black around it (default);
# `cover` fills the screen and crops
clock: true # default true
clock_position: wander # `wander` (default) moves it every `move_every` seconds;
# or a fixed spot: center, top-left, top, top-right, left,
# right, bottom-left, bottom, bottom-right
clock_size: 64 # height of the digits in px, 24-160; the date and the
# panel scale with it
clock_date: true # the day and date under the time; default true
move_every: 60 # seconds between clock positions while it wanders; default 60
frost: true # frosted panel behind the clock; default true
sleep: false # turn the backlight fully off while the screensaver runs
sleep_after: 0 # ...this many seconds into it (0 = at once, the photo is
# skipped; 600 = ten minutes of photo, then dark)
wake_on_proximity: true # default true
proximity_delta: 12 # how far the reading must move from its resting levelThe same keys also go under screensaver in a pushed setup.json, which is the right place
when different panels want different pictures; the dashboard card wins when both exist.
Sleep. With sleep: true the screensaver turns the LCD backlight fully off - not the
"Screen brightness" entity's 0, which on this panel is the driver's floor of 10/255 and still
glows in a dark room - and turns it back on for anything that wakes the screensaver: someone
walking up (the proximity sensor keeps reading in the dark), the Screensaver switch in Home
Assistant, or the wake command to the panel. A touch may or may not, depending on whether
the firmware wakes on touch; walk up instead. sleep_after keeps the photo up for that many
seconds first, so a panel can be a picture frame in the evening and dark overnight. Under the
hood the app puts the panel to sleep through its own adb daemon, the same path the updater
uses, and holds a partial wake lock while dark so the sensor and the MQTT connection stay
alive; if adb is not there the photo simply stays on and the log says why.
The clock wanders by default so that nothing sits on the same pixels all night. A fixed
clock_position is the choice when the photo has a spot for it; the panel's LCD does not
burn in the way an OLED would, so the wandering is a precaution rather than a necessity.
Proximity. The sensor reports a graded value at ~10 Hz, not near/far, and which way it moves
when someone approaches depends on the unit. Its resting level depends on the wall it hangs on
(58 on one wall in this house, 410 on another), its noise grows with that level, and the
display's own light reaches it, so the level shifts when the dark photo replaces the bright
dashboard. So the app ignores the first two seconds of the screensaver, takes the next two as
the resting level, and wakes when two readings in a row depart from it by proximity_delta
or 15% of the resting level, whichever is more. The setup screen (two-finger hold) shows the live value; watch
it as you walk up, and if you would rather be explicit, proximity_below: 30 or
proximity_above: 90 wake on an absolute threshold instead.
The frosted clock is the one BackdropFilter in this app, the exact thing the cards avoid
on this GPU. It is affordable here because nothing else is happening: the blur re-rasterises
only while the clock slides once a minute. frost: false if it ever stutters. Pictures are
decoded at the panel's size, not the photo's, and the previous one is evicted before the next
is fetched, so a stream of large photos does not grow memory.
Every push builds an APK on GitHub Actions — the Actions tab, latest run, under
Artifacts, nspanel-app-arm64. The panel is arm64; that is the only one it needs. Or build
it yourself:
flutter build apk --release --split-per-abi
adb connect <panel-ip>:5555
adb install -r build/app/outputs/flutter-apk/app-arm64-v8a-release.apkEnabling adb on the panel, and everything after this, is walked through step by step in TUTORIAL.md.
First launch asks for the Home Assistant URL, a long-lived access token (profile → Security →
bottom of the page), and optionally which dashboard (its url_path; empty for the default).
The token is stored on the panel only. To get that screen back later, hold two fingers
still on the dashboard for a second - a gesture no card uses, so it cannot fire one by
accident - or from your desk:
adb shell am force-stop nl.mennovanhout.nspanel
adb shell am start -n nl.mennovanhout.nspanel/nl.mennovanhout.nspanel_app.MainActivity --ez setup trueA wall panel has no comfortable way to type a 180-character token, so the app also takes its
settings from a file you push over adb. Write setup.json on your computer:
{ "url": "http://10.0.0.2:8123", "token": "eyJ...", "dashboard": "" }then push it into the app's own directory (no storage permission needed) and launch:
adb push setup.json /sdcard/Android/data/nl.mennovanhout.nspanel/files/setup.json
adb shell monkey -p nl.mennovanhout.nspanel -c android.intent.category.LAUNCHER 1The app reads it once, saves the settings, and deletes the file. Delete setup.json from your
computer too; it has your token in it. A later file is a partial update — only the keys in
it change — which is how you re-point a panel at a different dashboard, or adjust its
screensaver, from your desk without re-entering the token. The full set of keys is url,
token, dashboard, name, mqtt, tts_engine, screensaver and feedback.
Every touch-down clicks, and buzzes if the panel has a motor. The click is the built-in
pop through a SoundPool, the one Android path that plays within the touch's own frame
(a media player takes a tenth of a second to start, which reads as lag, not feedback). Both
are on by default; turn either off or set the click's volume in setup.json:
{ "feedback": { "sound": true, "vibrate": true, "volume": 0.5 } }The app comes back up by itself after a power cut, and after it has updated itself. Nothing
to enable. If you also want it to be the panel's home screen, so nothing else ever shows
and a stray Home press lands on the dashboard, there is a disabled Home alias you can
switch on from your desk:
adb shell pm enable nl.mennovanhout.nspanel/nl.mennovanhout.nspanel_app.Home
adb shell cmd package set-home-activity nl.mennovanhout.nspanel/nl.mennovanhout.nspanel_app.HomeThe stock launcher stays installed; set-home-activity l.l/... (its package is l.l) puts
it back.
Once the panel is a Home Assistant device (next section) it has an update entity: HA shows "0.3.0 available" with the release notes and an Install button, and the panel downloads the APK from this repo's GitHub Releases and installs it on its own - no adb, no one at the panel. Four panels are four Install buttons, or one automation.
How it can do that: the NSPanel Pro ships with adb listening on its own port 5555 with
authentication off (ro.adb.secure=0), so the app talks the adb protocol to
127.0.0.1:5555 and runs pm install -r on itself, exactly what you would do from your
desk. The install kills the running app; a broadcast receiver starts the new one, so from HA
it looks like a ten-second reboot. The setup screen (two-finger hold) shows the installed
version and has the same Update button, and the panel checks GitHub half a minute after
start and every six hours.
Releases come from a tag: git tag v0.3.0 && git push --tags and the Release workflow
builds the APK and attaches it. The signing key matters: Android only installs an update
over an app signed with the same key, so the workflow signs with the keystore in the
repository secrets (KEYSTORE_B64, KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) and
refuses to run without them, and a dev machine signs with the same key through
android/key.properties (gitignored). An APK you build without it is signed with your
debug key and installs fine - but a release will not install over it; uninstall first.
The panel is Android 8.1; the build targets API 24 and up. On the Mali-G31 the Flutter engine falls back from Impeller to Skia on its own (it says so in logcat at startup, worded as an "opt-out"); nothing to configure, and that is the renderer every measurement here was made on.
Give the app your MQTT broker and it registers itself through MQTT discovery: one device, "NSPanel Dining" or whatever you name it, with these entities.
| Entity | What |
|---|---|
sensor Proximity |
the graded reading, once a second at most |
binary_sensor Presence |
somebody at the panel, from a learned resting level (proximity_delta) |
sensor Illuminance |
lux, from the light sensor |
binary_sensor Screensaver |
whether the wallpaper is showing right now |
switch Screensaver |
put the panel to sleep or wake it from an automation |
number Page |
which page is showing; set it to turn the page |
number Screen brightness |
0–255 |
number Volume |
speaker, 0–100 |
notify Announce |
notify.send_message: text is spoken; a URL, an HA path, a media-browser file or a built-in sound is played |
update App |
the installed and latest version, release notes, and Install (see Updating) |
button Stop audio |
|
| Wi-Fi signal, SoC temperature, slow frames, app version, last touch | diagnostics |
Availability is an MQTT will, so the device goes unavailable the moment the panel drops off.
Configure it in setup.json (the token line can be left out when it is already stored):
{
"name": "NSPanel Dining",
"mqtt": { "host": "10.0.0.2", "port": 1883, "username": "mqtt", "password": "YOUR-MQTT-PASSWORD" },
"tts_engine": ""
}or on the setup screen. The MQTT client is hand-rolled and QoS 0 with retained state, tested against a fake broker the same way the HA client is.
Announcements. notify.send_message with a message speaks it: the app asks Home
Assistant's own TTS engine for the audio (/api/tts_get_url), so the voice is whichever you
have configured, and nothing is synthesised on the panel. Leave tts_engine empty to use the
first engine HA lists, or name one, e.g. tts.google_en_com. Try it from Developer tools →
Actions:
action: notify.send_message
target:
entity_id: notify.nspanel_dining_announce
data:
message: The washing machine is doneA message that names audio is played instead of spoken:
| Message | Plays |
|---|---|
sound:doorbell |
one of the built-in sounds, listed below |
/local/sounds/bell.mp3 |
a file in Home Assistant's www folder |
media-source://media_source/local/bell.mp3 |
a file from HA's media browser, resolved through HA |
https://…/bell.mp3 |
any URL |
JSON works too, and adds two things: wake brings the panel out of the screensaver first,
and volume sets the speaker for this announcement. Spoken text goes under message, a file
under url, a built-in sound under sound:
message: '{"message": "Dinner is ready", "wake": true, "volume": 60}'A doorbell, then, is one automation:
alias: Doorbell on the panels
triggers:
- trigger: state
entity_id: binary_sensor.doorbell
to: "on"
actions:
- action: notify.send_message
target:
entity_id: notify.nspanel_dining_announce
data:
message: '{"sound": "doorbell", "wake": true, "volume": 80}'The same JSON can be published straight to nspanel/<id>/say/set.
The built-in sounds. All synthesised (tool/sounds.py), so there is nothing to license
and nothing to host; an unknown name is logged with this list. alarm and ring are the loud
ones; battery, goodnight and pop are deliberately quiet.
| Sound | What it is |
|---|---|
doorbell |
ding-dong |
chime |
one bell |
notify |
two soft notes, a plain notification |
pop |
a click, for a button |
alert |
three beeps |
warning |
two falling tones, twice: a door left open, a leak |
alarm |
a siren, three seconds: smoke, intrusion |
armed |
three rising beeps |
disarmed |
three falling beeps |
success |
a rising arpeggio: something completed |
error |
two low buzzes: something failed |
knock |
three knocks on wood |
ring |
a phone ringing |
timer |
a kitchen timer |
laundry |
a little tune: the washer is done |
door_open |
a short slide up |
door_close |
a short slide down |
battery |
two quiet low beeps |
morning |
a gentle rising phrase |
goodnight |
a slow falling phrase, quiet |
Screen brightness needs the WRITE_SETTINGS permission, which Android grants only by a
one-time toggle on the panel - or from your desk:
adb shell appops set nl.mennovanhout.nspanel WRITE_SETTINGS allowUntil it is granted, the brightness entity reads but does not write.
There is no MQTT media_player platform in Home Assistant, so this does not make the panel
one; announcements, chimes and a volume slider are what a small mono speaker is for. A real
media_player entity would come from a DLNA renderer inside the app, which HA discovers on
its own - a separate piece of work.
Four moments animate, and only those: cards rise into place when a page first shows (fade and a 14px lift, 60 ms apart); the screensaver fades in over the dashboard; it fades out again, with touches reaching the dashboard the moment the fade starts rather than when it ends; and a new photo crossfades over the old with a soft push, the first one fading in rather than popping when its bytes land.
All of it is opacity and transform, each a one-off - the same budget the cards keep, because the panel is the reason this app exists. Nothing runs continuously, and nothing animates while you are dragging a card.
The first swipe. Skia compiles a shader the first time a draw op reaches this GPU, and
on the panel that was 150–270 ms per frame the first time a page was swiped in, on top of
building the page inside the gesture. So the pages next to the one on screen are built
ahead of time, without their entrance animation, and a second after load the app swipes
through every page once, hidden, to spend the compile time while nothing moves. After a
normal launch the first swipe now has no slow frame; after a fresh install, when Android's
shader cache is empty, one survives, and the next launch is clean. The Slow frames
diagnostic counts frames over 33 ms since launch, and adb logcat -s flutter names each
one with its build and raster time, so lag is a number you can chart in HA.
The rules that make the web cards feel right on this hardware are decisions, not web code,
and they are all here: no service call until the finger lifts, the local value winning for
echo_ms after a change, the bulb's colour clamped into a readable band, a scene at unknown
not being "broken", a button that acknowledges a tap the state will never confirm. The
websocket client is a port of the one verified against a fake HA in the cards repo, and is
tested the same way here (flutter test).
Not here: Home Assistant's more-info dialog. Where the web cards open it, this app opens the card's own sheet (climate) or does nothing (read-only cards). And only these twelve card types render; this is a panel, not a browser.
lib/ha/ Home Assistant websocket, entity state with a notifier per entity
lib/mqtt/ MQTT 3.1.1 client (hand-rolled) and the HA-discovery bridge
lib/update/ GitHub Releases check, and an adb client for installing over 127.0.0.1:5555
lib/audio/ the speaker: TTS via HA, URLs, media-browser files, built-in sounds
lib/config/ settings (url, token, dashboard, mqtt, ...), Lovelace -> pages, screensaver
lib/ui/ the shared drag surface, the long-press sheet, the pager, screensaver, setup
lib/cards/ one file per card, plus the registry that maps type -> widget
lib/util/ colour clamp, icon lookup, number formatting, the panel's sensors
assets/sounds/ the built-in sounds, generated by tool/sounds.py together with lib/audio/sounds.dart
test/ colour, config parsing, the HA protocol and MQTT against fakes