Enhanced notifications for Android TV / Fire TV — show popups (text, images, video or live camera streams) on your TV from your home-automation system, for as long as you want.
Credits: PiPup was originally created by Rob Groenendijk (rogro82). This repository is a maintained fork of that project — all credit for the original idea and implementation goes to him. The fork modernizes the build (AndroidX, AGP 8, Kotlin 2, targetSdk 34) and adds the features below, aimed at Home Assistant use.
Contributors to this fork: David Bebawy (davbebawy) built several popups at once, push to a webhook, see-through popups and the configurable update source (0.24.0); andrewm1205 added the top/bottom center positions (0.22.0).
What this fork adds (compared to rogro82/PiPup)
- WHEP streams (since 0.25.0) —
media.whepplays a WebRTC stream straight from a WHEP endpoint (go2rtc/api/webrtc?src=…), about a second faster and far steadier than go2rtc's player page. See Which stream should a camera popup use?. - Several popups at once (since 0.24.0) — each popup
idis its own window, so a doorbell camera and a waste reminder can stand side by side./state.popupslists them in stack order,/cancel?all=trueclears the screen. See Cancelling a popup. - Push instead of poll (since 0.24.0) —
POST /settings?webhook=<url>makes the app POST its/stateplus anevent(popup_shown,popup_removed,screen_off, …) on every change, so a controller knows within milliseconds instead of at its next poll. See Settings. - See-through popups (since 0.24.0) —
opacity(0..1) on any popup, andtransparent: trueon web media so a page with a transparent body floats over live TV. - Update source and switch (since 0.24.0) — the release check can be switched off for TVs without
internet, or pointed at another GitHub repo or a LAN folder with
releases.json. - Indefinite popups —
duration: 0(or negative) shows a popup until it is cancelled or replaced, e.g. show a camera stream for exactly as long as there is motion. - Popup
id+ update-in-place — re-sending a notify with the sameidand content only reschedules the removal timer without rebuilding the view, so a video/web stream keeps playing without flicker. - Companion-version panel (since 0.21.0) —
/statecarries ahaPipupobject:recommended(the latest ha-pipup release, fetched with the hourly update check — never maintained by hand),minimum(oldest integration that can drive this app's full API, a build-time constant) andconnected(what actually talks to us: ha-pipup ≥ 1.18.0 announces itself with anX-HA-PiPup-Versionheader on every request, including the 15s/statepoll). The app's own status screen shows the same line: ✓ up to date, update available, or too old. /stateendpoint — popup visibility, screen on/off (screenOn, since 0.2.3), popup counter, uptime, device info and a stable device id (since 0.2.5)./cancel(existed upstream but undocumented) with optional selective?id=./notifyand/cancelanswer once/statereflects the change (since 0.17.1) — a200means the popup is on (or off) screen, so a client may read/statestraight after the call. The reply waits for the view, not for its media to load. A popup that fails to build answers500.- Muted media (since 0.2.4) —
muted: trueon video/web media plays without audio, so a popup never claims audio focus (audio in a popup can freeze video playback on some devices). - Text-to-speech (since 0.2.5) — a
ttsfield speaks a text on the TV when the popup appears, with optionalttsLanguage(BCP-47). - mDNS/zeroconf discovery (since 0.2.5) — the app advertises
_pipup._tcpwith a stable device id, so clients (like the Home Assistant integration) find TVs automatically and follow them across DHCP address changes. - Overlay watchdog (since 0.2.6) — popup removal is guarded step-by-step and a 30s consistency
check force-removes any overlay left behind by a failed teardown, so a popup can no longer stay
on screen after its dismiss.
/statereportswatchdogCleanupsso you can see if it ever fired. - Buttons on the popup (since 0.3.0) —
buttons: [{id, label}]renders remote-operable buttons: the overlay only becomes focusable when buttons are present (it never steals the remote otherwise), OK activates (POST{popup, button, label, device, name}to thecallbackURL and dismiss), BACK dismisses without an action. - Countdown bar (since 0.3.0) —
showProgress: trueanimates a progress bar over a finite duration. - Urgency presets (since 0.3.0) —
urgency: info|warning|criticaladds a blue/orange/red border. - Custom border styling (since 0.7.0) —
borderColor,borderWidthandcornerRadiusstyle the popup frame yourself; each field overrides its part of theurgencypreset, so the preset stays a shorthand andborderWidth: 0switches its border off again. Also available on uploaded snapshots (multipart), which previously ignoredurgencyandshowProgressentirely. - Icon beside the text (since 0.13.0) — an optional
icon(image URL) shown next to the title/message, notification-style, withiconPosition(left/right) andiconWidth. - Seen over the screensaver (since 0.18.0) — when the TV's screensaver / ambient mode is showing, the
app ends it before showing the popup (the same wake path as
/power), because on several Android builds the dream layer covers app overlays and Android 12+ lets it hide them.dismissScreensaver: falsekeeps the screensaver;/state.dreamingreports the current state. - Compact buttons (since 0.19.0) —
buttonSize(sp) scales button text and padding together, so a popup with buttons can be small; omit it for the classic look. - Entrance/exit animations (since 0.19.0) —
animation: fade | slide_left | slide_right | slide_top | slide_bottom, played when the popup is built and again (reversed) when it expires naturally. An update-in-place never re-animates, and replace//cancelstill remove instantly. - Notification sound (since 0.18.0) —
sound: "default"plays a built-in chime when a popup is newly shown, or give a URL to your own clip;soundVolume(0–1). Not replayed on an update-in-place. - Poster (since 0.17.0) — an optional
poster(image URL) onvideoandwebmedia: a still (e.g. a camera snapshot) shown over the stream area the moment the popup appears and faded out on the stream's first rendered frame. A live popup never opens as an empty box while the RTSP handshake or WebView start-up runs; the stream area takes the poster's aspect, so still and live line up. If the stream never paints the poster simply stays. On a web page that hosts a<video>(e.g. go2rtc's WebRTC viewer) the fade waits for actual playback (since 0.20.0), not for the page paint — so WebRTC + poster gives an instant still followed by near-realtime video./state.lastPopup.firstFrameMsreports the time to first frame. - Screen on/off (since 0.7.0) —
POST /power?state=on|off|togglewakes the TV or puts it in standby, without a second integration for ADB or HDMI-CEC./statepublishes what is actually possible on this device (power.canSleep,power.sleepMethod) instead of accepting a request it cannot honour. See Screen on/off. - Permission screen with fix buttons (since 0.8.0) — the status screen on the TV lists every
permission with its actual state, and a Fix button next to the missing ones that jumps straight
to the system screen where it is granted, operable with the remote.
POST /permissions/fixdoes the same from a controller (the Home Assistant integration has a button, an action and a self-fixing repair). Where a device has no such screen — or where the permission is one the device actively blocks (some TVs lock "install unknown apps" for sideloaded apps at system level, e.g. Samsung's Auto Blocker; since 0.11.1) — the app shows the adb command instead of a dead button. See Permission screen. - Permission reporting + installers (since 0.7.0) —
/statereports what the app was granted (permissions.overlay,installPackages, vendorautoStart,deviceAdmin,accessibility) and the status screen on the TV warns when the overlay permission is missing — until now that failure was invisible: every popup was answered with HTTP 200 and nothing appeared.install.sh/install.ps1ship with each release and do the whole install including the app-ops that an app cannot grant itself and that every reinstall resets. - Localization (since 0.3.1) — the app UI follows the device language (English/Dutch).
- Lazy TTS engine (since 0.4.0) — the speech engine is only bound when a popup actually carries
a
ttsfield and is released again after 60s idle. On Google TV devices this keeps the separate ~100MBcom.google.android.ttsprocess out of memory, which matters a lot on 1GB TVs where the low-memory killer picks the heaviest processes. - Restart after an update (since 0.6.2) — the app listens for
MY_PACKAGE_REPLACED, so the service comes back by itself after a self-update (or anadb install -r). Before this, replacing the APK left the TV silently offline until something started the service again. - Starts on a silent power-restore boot (since 0.12.0) — the boot receiver and the service are
directBootAwareand also listen forLOCKED_BOOT_COMPLETED, so the service comes up in the early locked-boot phase.BOOT_COMPLETEDalone is only broadcast once the device reaches an unlocked session, which a TV that boots to standby after a mains-power cut may never reach until it is turned on — so before this the app stayed down after a power outage until it was opened by hand. App prefs (device id, version markers) live in device-protected storage so they survive direct boot; the id is migrated in place and stays the same. - Visible updates (since 0.11.0) — an update started via the Install button (or
POST /update) shows an "Installing PiPup vX…" popup with a countdown, and where the system demands on-screen confirmation (Android < 12) the app turns that into a popup with a button — a press gives the installer the visible window it needs, instead of a confirmation dialog that flashes past and strands the update. - Crash fix: repeated start requests (since 0.6.1) —
startForeground()is now called on everystartForegroundService()(i.e. also inonStartCommand), not only on creation. Without it Android killed the process withRemoteServiceException: Context.startForegroundService() did not then call Service.startForeground(), so every keep-alive attempt — an automation, or the connectivity Receiver — crashed the app instead of keeping it alive.onStartCommandalso revives the web server when it is no longer alive. - Resilient web server startup (since 0.4.0) — binding port 7979 is retried (3 attempts, 500ms apart) and a definitive failure stops the service for a clean restart, instead of leaving a live process with a dead server behind.
- Self-update (since 0.6.0) — the app checks the fork's GitHub releases twice a day and can install a
newer version itself: it announces a new release once on screen with an Install button, exposes
updatein/state, and acceptsPOST /updateto trigger the update (used by the Home Assistant integration's update entity). Android only accepts an APK signed with the same key, so a tampered download can never replace the app. On Android 12+ the self-update is silent; on older devices the system shows its install confirmation on the TV, which someone has to accept with the remote (see the limitation below). Grant the install permission once (survives updates, not reinstalls):adb shell appops set nl.rogro82.pipup REQUEST_INSTALL_PACKAGES allow - WebView media supports JavaScript, DOM storage and unattended (autoplay) playback, and cleartext (http) LAN URLs are allowed — required for camera streams from e.g. go2rtc/Frigate.
- Assorted fixes (request-body handling, message size/color defaults, WebView cleanup).
Home Assistant users: there is a companion integration —
mhoogenbosch/ha-pipup — with a config flow per TV,
a popup binary sensor and pipup.show / pipup.dismiss actions (including camera entities).
Short answer: whep + poster for anything where "now" matters (doorbell, motion), when you run
go2rtc (stand-alone or inside Frigate):
{ "id": "doorbell", "duration": 0, "media": { "whep": {
"uri": "http://go2rtc:1984/api/webrtc?src=doorbell",
"width": 720, "height": 540,
"poster": "http://frigate:5000/api/doorbell/latest.jpg"
}}}The poster (a camera snapshot) is on screen at once; the live WebRTC picture takes over on its first frame, at full frame rate and well under a second behind reality. Without go2rtc, MJPEG is a fine zero-dependency fallback; RTSP sits in between and needs no web page.
The trade-off between the routes is start-up time versus how far the picture lags behind reality:
| Route | First live frame | Live lag | Notes |
|---|---|---|---|
WHEP (whep → go2rtc /api/webrtc?src=<cam>, since 0.25.0) |
2.2–2.8 s | < 0.5 s | Recommended. WebRTC without go2rtc's player page. |
WebRTC page (web → go2rtc stream.html?src=<cam>&mode=webrtc) |
2–7 s (up to ~11 s on slow webviews) | < 0.5 s | Same stream as WHEP, but the page, its player script and websocket signalling come first. Use only on an app older than 0.25.0. |
RTSP (video → rtsp://…) |
4–6 s | ~1 s | ExoPlayer, RTP over TCP; renders over playing video. |
MJPEG (web → Frigate /api/<cam>?fps=5) |
0.2–0.9 s | 2–3 s | Fastest start, but choppy (detect fps) and behind: camera GOP + Frigate's detect pipeline + frame sampling. |
HLS (video → …m3u8, camera_mode: stream) |
7–12 s | 5–10 s | Avoid for live viewing; fine for non-urgent clips. |
Fire TV (AFTKA, Android 9), app 0.25.0, alternating runs, time from the popup request to the first
rendered frame (/state.lastPopup.firstFrameMs):
| Stream | WebRTC page in web |
whep |
Frigate MJPEG |
|---|---|---|---|
deurbel (main, 2560×1920, 10 fps), 6 runs |
3423 ms (1977–6635) | 2343 ms (2207–2536) | 906 ms (883–934) |
deurbel_noaudio (go2rtc re-stream of the sub-stream), 5 runs |
6357 ms (5589–6698) | 2524 ms (2262–2772) * | — |
main streams after setting the camera keyframe interval to 1 s (gop 1), 6 runs over 4 cameras |
— | 1.0–1.6 s (avg 1.3 s) | — |
* Measured while that stream happened to be warm from the runs before. Cold, as in real use, it took 6.4 s: go2rtc answered only after 4.2 s because it had to start the stream first.
Where WHEP's time goes (its logcat timeline): offer after 73 ms, go2rtc's answer after 257 ms, video track after 354 ms, first frame after about 1.5 s. The rest of the wait is the camera's keyframe: WebRTC can only show a picture from the next I-frame, so a shorter I-frame interval on the camera starts every WebRTC route sooner (going from a 2 s to a 1 s interval brought the main streams down to 1.0–1.6 s).
Use a stream go2rtc is already pulling. A stream with no other viewer is started on demand: go2rtc
answers only once it has connected to the camera, which took 3–4 s here. The main stream Frigate
records from is always running, so its answer comes in ~0.3 s. A separate "no audio" re-stream is not
needed: with muted (the default) the app asks for video only. Check with go2rtc's /api/streams: a
stream with consumers is warm.
The poster (since 0.17.0) makes start-up time matter much less: a still of the same camera shows
instantly and fades on the stream's first frame. For whep that moment comes from the app's own
<video> element. For a web page the app injects a watcher: since 0.20.0 the poster waits until
the page's video actually plays (not when the page paints), and since 0.20.1 there is no time limit
once the page is seen to contain a <video>. Only a page where no video is ever found falls back to
showing the page after 20 s.
Minimum: Android 6.0.1 (API 23). Built for Android TV / Fire TV, but since 0.14.0 it also installs on plain-Android devices (projectors, TV boxes) and gets a normal launcher icon there.
Everything core works on every supported version — popups (text, image, video, web, camera),
muted media, TTS, remote-operable buttons, countdown bar, urgency/border styling, the icon beside the
text, /state, /notify, /cancel, mDNS discovery, the overlay watchdog, and turning the screen
on. A few things depend on the Android version:
| Capability | Works on |
|---|---|
Popups, TTS, buttons, styling, /state, screen on |
6.0.1+ (all) |
| Self-update | 6.0.1+ since 0.19.3 (bundles ISRG Root X1 — Android < 7.1.1 lacks the Let's Encrypt root that GitHub's download hosts use) |
| Overlay rendering | 6–7 via TYPE_SYSTEM_ALERT (needs the overlay app-op — the installer grants it); 8+ via TYPE_APPLICATION_OVERLAY |
video_url — rtsp://, HLS .m3u8 (incl. camera_mode: stream), progressive http (since 0.16.0) |
6.0.1+, via ExoPlayer rendered in a TextureView — composited by the GPU inside the popup, so it also shows over video the TV is already playing (verified on a Fire TV with a film running). RTSP uses RTP-over-TCP. Audio only with muted: false. Not for DRM content (irrelevant for cameras). On Android < 8 and Amlogic SoCs video is decoded in software by default (since 0.17.2) because the vendor decoder froze the HDMI input on release; softwareDecoder overrides. |
| MJPEG camera streams | use web_url (or the HA integration's camera_mode: mjpeg), never image_url — image_url decodes a single still image and cannot render a multipart MJPEG stream (it shows only the text). For a still, point image_url at a snapshot such as Frigate's /api/<cam>/latest.jpg. |
Screen off (POST /power?state=off) |
any version, after a one-time device-admin or accessibility grant (--power / --accessibility); which route works depends on the device |
| Silent self-update | 12+ only. On older devices the update still works but the system shows an install confirmation the app wakes the screen for and turns into a popup with a button — one press on the remote finishes it (see the limitation) |
Restart after a silent power-restore boot (LOCKED_BOOT_COMPLETED) |
7.0+ (direct boot). On Android 6 there is no direct boot, so a restart relies on the normal BOOT_COMPLETED — fine on a TV/projector without a lock screen |
specialUse foreground-service type |
14+ (cosmetic; older run a normal foreground service) |
Android 6 is not hardware-tested by the maintainer (no API 23 device on hand) — the code paths are version-guarded and the APK builds and runs without regression on the Android 9+ fleet, but if you hit something on a 6.0.1 device please open an issue with the
/permissions/diagnoseoutput.
Sideloading requires ADB over the network, which is off by default:
- Android TV / Google TV: Settings → System → About → press Build number 7 times (unlocks Developer options) → Settings → System → Developer options → enable USB debugging (on recent Google TV also Wireless debugging).
- Fire TV: Settings → My Fire TV → About → press the device name 7 times → My Fire TV → Developer options → enable ADB debugging.
Then connect from your computer with adb connect <tv-ip>:5555 and accept the
authorization prompt on the TV (once per computer).
Grab install.sh (Linux/macOS/WSL) or install.ps1 (Windows) from the
releases page and point it at your TVs:
./install.sh 192.168.1.10 192.168.1.11 # downloads the latest APK itself
./install.sh --power --apk PiPup.apk 192.168.1.10
# TCL Google TV: the accessibility keep-alive is enabled by default (see the TCL section)
It installs the APK, grants the app-ops below, starts the service and verifies over HTTP that the
app is actually answering with the overlay permission in place. --power also activates the device
admin (for screen off), --accessibility enables the fallback for that, and --force-uninstall
handles a differently-signed build that is already installed — note that uninstalling wipes the
app's stable device id, so Home Assistant sees a new device afterwards.
Sleeping TVs are left asleep: the service is started in the background, which does not touch
what is on screen. On a TCL Google TV use --wake, because its vendor guard freezes a service
started from the background (see below) — there the app has to come up in the foreground.
This is also the way to update Android < 12 TVs silently. The in-app self-update installs silently only on Android 12+; on older devices the OS forces an on-screen confirmation for any app-initiated install (a platform limit — see self-update). A shell-initiated
adb install -r, which is whatinstall.shdoes, has no confirmation on any Android version. Run it on a schedule (cron, or a Home Assistantshell_command) to keep older TVs updated with no interaction — the ha-pipup readme has a ready-made automation.
Doing it by hand
adb connect <tv-ip>:5555
adb install -r PiPup.apk
adb shell appops set nl.rogro82.pipup SYSTEM_ALERT_WINDOW allow
adb shell appops set nl.rogro82.pipup REQUEST_INSTALL_PACKAGES allow
The overlay permission has no settings UI on Android TV, and the install permission is what lets the
app apply its own updates. Both are app-ops: an app cannot grant them to itself (that is
shell/system territory), which is why they are handed out over adb — and why adb install -r
resets them, so they have to be granted again after every install. /state and the status screen
on the TV show whether the overlay permission is currently in place.
If you have the original Play Store version installed you need to uninstall that first (different signature, same application id).
After installation or updating, open the application once (or reboot the TV) to make sure the background service is running. Starting the service without bringing the app to the foreground (handy from an automation, it does not interrupt whatever is playing) also works:
adb shell am start-foreground-service -n nl.rogro82.pipup/.PiPupService
Not on TCL Google TV. There a background start lands the service at
oom_score_adj500 and the vendor guard freezes it within seconds. Start the activity instead — see below.
TCL ships an extra guard (com.tcl.guard) with two separate mechanisms. Both look like an app bug
and neither is caused by memory pressure — measured on a 1 GB set, PiPup used 26 MB PSS while the
launcher used 119 MB and the screensaver 91 MB.
The fix that covers both: enable PiPup's accessibility service. The installers on master do this by
default on TCL, shipping with the first release after 0.18.1 (--no-accessibility / -NoAccessibility
to opt out). The service itself is
dormant — it observes nothing and the app only uses it as the screen-off fallback — but once it is
enabled, system_server keeps it bound, and a process with a system-bound service sits at
oom_score_adj 100 ("visible"): above anything the guard kills or freezes, and above the 200 the
activity route below reaches. Measured on a TCL Google TV (Android 11):
$ adb shell 'P=$(pidof nl.rogro82.pipup); cat /proc/$P/oom_score_adj'
100
$ adb shell dumpsys activity processes | grep -A1 nl.rogro82.pipup
Proc #12: vis F/S/FGS nl.rogro82.pipup (service)
nl.rogro82.pipup/.PiPupAccessibilityService <= Proc{741:system/1000}
A second owner whose TCL had been dropping off for weeks went from 500 to 100 with this one change (issue #38). TCL's accessibility settings screen is a stub, so it has to be adb — and append, or you switch off every other accessibility service on the set:
adb shell 'cur=$(settings get secure enabled_accessibility_services); settings put secure enabled_accessibility_services "${cur:+$cur:}nl.rogro82.pipup/nl.rogro82.pipup.PiPupAccessibilityService"; settings put secure accessibility_enabled 1'
Like every grant it survives updates via install.sh, which re-applies it. The two mechanisms below are
what you are up against without it, and how to recover a TV that is already stuck.
1. It blocks the automatic restart of a killed service unless the app holds the vendor-specific
APP_AUTO_START app-op — it logs forbid restart Servic ... callee_does't_have_OP_AUTO_START_permission
and the service never comes back after a kill. The on-screen menu ("Permission Guardian" →
"Auto-start permission") keeps per-app entries locked while its "Automatic management" master switch
is on, so grant the op over adb instead (note the internal name android:auto_start; the displayed
name APP_AUTO_START is not accepted):
adb shell cmd appops set nl.rogro82.pipup android:auto_start allow
adb shell dumpsys deviceidle whitelist +nl.rogro82.pipup
Like the overlay permission the app-op resets on reinstall, so repeat it after every update. On
brands without this op (Fire TV, Nokia, …) the command fails with Unknown operation string — that
is fine, nothing needs granting there. The deviceidle entry survives reboots and stops
am_stop_idle_service from tearing the service down.
2. It freezes processes (persist.sys.freeze=true, independent of the AOSP freezer). A frozen
process is alive but SIGSTOPped, which is why this failure mode is so confusing: ps still
lists PiPup while port 7979 no longer answers, so clients hang in a timeout instead of getting a
connection error. Recognise it like this:
adb shell 'P=$(pidof nl.rogro82.pipup); grep freezer /proc/$P/cgroup; cat /proc/$P/oom_score_adj'
# frozen -> 5:freezer:/frozen ... 500
# healthy -> 5:freezer:/thaw ... 200
adb shell netstat -ltn | grep 7979 # frozen: Recv-Q > 0 on LISTEN, plus CLOSE_WAIT rows
Incoming traffic does not thaw the app; only bringing it to the foreground does. Without the
accessibility service above, what keeps it running is oom_score_adj 200, and the app only reaches
that when it is started from a foreground context, i.e. via the activity:
adb shell input keyevent KEYCODE_WAKEUP # only needed while the screensaver is on
adb shell am start -n nl.rogro82.pipup/.MainActivity
adb shell input keyevent KEYCODE_HOME
Started this way the app stays up, screensaver included. Two caveats: am start -W hangs while
the TV is dreaming (use it without -W), and this briefly takes over the screen, so avoid it while
someone is watching. It is worth automating the recovery — the
ha-pipup integration README
has a ready-made Home Assistant automation, including the pitfall that a ps | grep pipup guard
silently defeats it (a frozen process is still listed).
PiPup runs an embedded webserver (NanoHTTPD) on port 7979 with no authentication,
and a popup's web media is rendered in a WebView with JavaScript and DOM storage enabled.
That means any device on the same network can display arbitrary content — including
JavaScript — on the TV. This is by design (camera/stream pages need it), but it makes the
trust boundary the network itself.
- Run PiPup TVs on a trusted network segment (not a guest/IoT VLAN that untrusted devices share).
- Traffic is plain HTTP (
usesCleartextTraffic), so treat everything sent to the popup — URLs, TTS text, button callbacks — as visible on the LAN. - Button presses POST to the
callbackURL supplied with the popup. If you drive security-sensitive automations from button events (e.g. unlocking a door), have the caller include an unguessable, single-use token in that callback URL and verify it on receipt — the ha-pipup integration does this automatically. - Since 0.7.0 the same unauthenticated port also accepts
POST /power, so anyone who can reach the TV can switch its screen on or off. That is annoying rather than dangerous, but it is a reason not to grant the screen-off route (device admin / accessibility) on a TV you deliberately expose. Granting nothing leaves/power?state=offreturning 501, and the device admin only asks forforce-lock— no password, camera or wipe policies — so the worst an attacker gains is a TV that goes to standby. - Since 0.24.0
/settingsis on the same port. Anyone on the LAN can point the push webhook at their own host (and so read every later state change) or change the update source. The update source cannot install a foreign app: Android only accepts an update signed with PiPup's own key.
PiPup runs an embedded webserver (NanoHTTPD) on port 7979.
| Property | Value |
|---|---|
| Path: | /notify |
| Method: | POST |
| Content-Type: | application/json |
Example:
{
"duration": 30,
"id": "doorbell",
"position": 0,
"title": "Your awesome title",
"titleColor": "#0066cc",
"titleSize": 20,
"message": "What ever you want to say... do it here...",
"messageColor": "#000000",
"messageSize": 14,
"backgroundColor": "#ffffff",
"media": { "image": {
"uri": "https://your.host/image.png", "width": 480
}}
}All fields are optional. For media you can specify 4 types:
{ "image": { "uri": "address_to_your_image", "width": 480 }}
{ "video": { "uri": "address_to_your_video", "width": 480, "muted": true }}
{ "web": { "uri": "address_to_your_resource", "width": 640, "height": 480, "muted": true }}
{ "whep": { "uri": "http://go2rtc:1984/api/webrtc?src=doorbell", "width": 640, "height": 480 }}whep (since 0.25.0): a WebRTC stream played straight from a
WHEP (IETF draft) endpoint, such as go2rtc's /api/webrtc?src=<stream>.
The app loads a small player page of its own (with the endpoint's origin as base, so no CORS setup is
needed), sends one SDP offer, and plays the answer. Compared with go2rtc's stream.html in a web
popup this skips the page download, its player script and the websocket signalling. muted defaults
to true (no audio track is requested); poster and transparent work as for web. On a failed
offer or a dropped connection it retries with backoff (1, 2, 4, then every 8 s).
/state.lastPopup.mediaError holds the last error (for example HTTP 404 for an unknown stream
name); with adb, logcat -s PopupView shows a timeline per attempt (offer, answer, track, playing).
poster (since 0.17.0, video and web; whep since 0.25.0): URL of a still image shown over the stream area until the
stream renders its first frame, then faded out. Use a camera snapshot (e.g. Frigate
/api/<cam>/latest.jpg) so the popup shows a picture instantly instead of an empty frame while
RTSP connects or the WebView starts. The stream area takes the poster's aspect, so still and live
match. If the poster fails to load nothing happens; if the stream never paints, the poster stays.
/state.lastPopup.firstFrameMs reports the time to first frame.
{ "video": { "uri": "rtsp://cam/sub", "width": 640, "poster": "http://frigate:5000/api/cam/latest.jpg" }}softwareDecoder (since 0.17.2, video only, default automatic): true decodes in software and never
touches the vendor hardware decoder; false forces the hardware decoder; absent = automatic — software on
Android < 8 and wherever an Amlogic H.264 decoder is present, hardware everywhere else. Reason: on
Amlogic SoCs the MediaCodec decoder and the HDMI input share one video layer, and releasing the decoder when
the popup closed froze the HDMI picture behind it (Xiaomi laser projector, Android 6.0.1). Software
decoding is fine for a camera sub-stream (640×480 … 720p); a 1080p main stream may be heavy on a low-end
projector — use the sub-stream there. Decoder fallback is on, so a failing software decoder falls through to
hardware. /state.lastPopup.media.softwareDecoder echoes the requested value (null = automatic).
{ "video": { "uri": "rtsp://cam/sub", "width": 640, "softwareDecoder": true }}dismissScreensaver (since 0.18.0, default true): end an active screensaver / ambient mode before the
popup is shown, so it is seen on every Android version (some dream layers cover app overlays; Android 12+
can hide them). false leaves the screensaver running — the popup may then be invisible on such devices.
sound (since 0.18.0, default none): "default" plays the built-in chime, any other value is a URL/URI of
an audio clip (mp3/ogg/wav) when the popup is newly shown; soundVolume (0–1) scales it. The built-in chime
is 1.9 s with a 300 ms silent lead-in: an HDMI/eARC audio path needs a few hundred ms to open when a new
stream starts, and a very short clip disappears in that gap — give your own clip a lead-in too. An update-in-place
of the same popup does not replay it. Uses transient audio focus with ducking; like tts this opens an audio
path, which on some Fire TVs briefly renegotiates HDMI audio.
{ "id": "doorbell", "title": "Front door", "sound": "default", "soundVolume": 0.8 }opacity (since 0.24.0, 0..1, default 1): draws the whole popup, media included, at that alpha, so the
picture behind it stays visible. Works with every media type and with animation.
transparent (since 0.24.0, web media only, default false): the WebView paints no background, so a
page with a transparent html, body { background: transparent } shows the TV through it. Combine with
"backgroundColor": "#00000000" and "padding": 0 for a frameless, see-through overlay:
{ "id": "score", "duration": 0, "padding": 0, "backgroundColor": "#00000000",
"media": { "web": { "uri": "http://192.168.1.96:8123/local/score.html",
"width": 420, "height": 900, "transparent": true } } }padding (since 0.19.1, px, default 20): the popup's outer margin around content; 0 gives a
near-borderless look.
buttonSize (since 0.19.0, sp): scales the popup buttons' text and padding together. Without it the
buttons look exactly as before.
animation (since 0.19.0, default none): fade, slide_left, slide_right, slide_top or
slide_bottom. Plays when the popup is built; an update-in-place of the same popup does not re-animate.
A naturally expiring popup animates out the same way; replace and /cancel remove instantly.
muted (since 0.2.4, default false): plays the video/web media without audio. For web media every
(also dynamically added) <video>/<audio> element on the page is muted, so the page never claims
audio focus — audio in a popup can freeze video playback on some Android TV / Fire TV devices.
tts (since 0.2.5): a text that is spoken aloud on the TV when the popup appears, using the
device's text-to-speech engine. Optional ttsLanguage takes a BCP-47 tag (e.g. "nl-NL");
the device's default locale is used when omitted. Re-sending the same popup id with unchanged
content and unchanged tts does not repeat the speech (only the removal timer is extended);
sending a different tts text speaks the new text.
{ "title": "Doorbell", "tts": "Er staat iemand voor de deur", "ttsLanguage": "nl-NL" }Since 0.3.0 three more optional fields:
{
"urgency": "critical",
"showProgress": true,
"buttons": [{ "id": "unlock", "label": "Open the door" }],
"callback": "http://your-ha:8123/api/webhook/pipup_buttons"
}urgency (info/warning/critical) adds a blue/orange/red border. showProgress animates a
countdown bar over a finite duration. buttons (with a callback URL) renders remote-operable
buttons: the overlay only takes input focus when buttons are present, OK activates the focused
button — the app POSTs {"popup", "button", "label", "device", "name"} to the callback and
dismisses — and BACK dismisses without an action.
Since 0.7.0 the border can be styled directly, beyond the three urgency presets:
{
"borderColor": "#00E5FF",
"borderWidth": 10,
"cornerRadius": 28
}| Field | Type / default |
|---|---|
borderColor |
String [AA]RRGGBB (default: the urgency color, else #ffffff) |
borderWidth |
Integer pixels (default: the urgency width, else 4 when a color is given; 0 = no border) |
cornerRadius |
Number pixels (default: 8 when a border is drawn, else 0) |
Each field independently overrides the urgency preset, so the two combine: urgency: "critical"
with borderWidth: 2 keeps the red but makes it thin, and borderWidth: 0 removes the preset's
border while keeping any other styling. cornerRadius works without a border too, for rounded
corners on a plain popup. Sizes are in pixels, like every other dimension in this API (media
width, padding) — on a 1080p TV a border of 10 is comfortably visible. An unparseable color falls
back to the default instead of dropping the popup.
Since 0.13.0 an icon can be shown beside the title/message (notification-style):
{
"icon": "http://your-ha:8123/local/icons/doorbell.png",
"iconPosition": "left",
"iconWidth": 96
}icon is an image URL, loaded like the other media. iconPosition is left (default) or right;
iconWidth is in pixels (default 96, aspect ratio preserved). The title and message sit in a column
next to the icon, and the media image (if any) stays below.
duration: seconds to show the popup.0or negative shows it indefinitely, until/cancelis called or a new popup replaces it.id(string, optional): identifies the popup. Since 0.24.0 each id is its own popup: a popup with a new id opens beside the ones already on screen (newest on top), each in its own window sized to its content. Popups without anidshare one slot and replace each other. There is no limit on how many are up; what a TV can draw smoothly depends on the TV (one video at a time is typical for a 1-2 GB TV). Re-sending the sameidwith identical content only reschedules the removal timer; the view (and a playing video/web stream) is kept as-is. Sameidwith new content redraws that popup in place, keeping its place in the stack.bringToFront(since 0.24.0, defaultfalse): a redraw of a popup already on screen opens it on top of the others instead of in its old place.
| Property | Value |
|---|---|
| Path: | /notify |
| Method: | POST |
| Content-Type: | multipart/form-data |
Form-fields:
| Field | Type |
|---|---|
| duration | Integer (default=30, 0=indefinite) |
| id | String (optional popup identifier) |
| position | Integer (0..4, default=0) |
| title | String |
| titleSize | Integer (default=16) |
| titleColor | string (default=#FFFFFF, format=[AA]RRGGBB |
| message | String |
| messageSize | Integer (default=12) |
| messageColor | String (default=#FFFFFF, format=[AA]RRGGBB |
| backgroundColor | String (default=#CC000000, format=[AA]RRGGBB |
| image | File |
| imageWidth | Integer (default=480) |
| tts | String (optional, spoken aloud, since 0.2.5) |
| ttsLanguage | String (optional BCP-47 tag, since 0.2.5) |
| urgency | String info/warning/critical (since 0.7.0) |
| borderColor | String (format=[AA]RRGGBB, since 0.7.0) |
| borderWidth | Integer pixels (since 0.7.0) |
| cornerRadius | Number pixels (since 0.7.0) |
| icon | String image URL (since 0.13.0) |
| iconPosition | String left/right (default=left, since 0.13.0) |
| iconWidth | Integer pixels (default=96, since 0.13.0) |
| showProgress | Boolean (default=false, since 0.7.0) |
position is an enum ranging from 0 to 6:
| Position | |
|---|---|
| 0 | TopRight |
| 1 | TopLeft |
| 2 | BottomRight |
| 3 | BottomLeft |
| 4 | Center |
| 5 | TopCenter (since 0.22.0) |
| 6 | BottomCenter (since 0.22.0) |
Color-properties are in [AA]RRGGBB where the alpha channel is optional, e.g. #FFFFFF or #CCFFFFFF.
| Property | Value |
|---|---|
| Path: | /cancel |
| Method: | POST |
Since 0.24.0 several popups can be up at once:
POST /cancel?id=doorbellremoves the popup with that id and leaves the others.POST /cancel(no id) removes the popup that was sent without an id.POST /cancel?all=trueremoves every popup.
When nothing matches, the call is a no-op (HTTP 200 with an explanatory message).
| Property | Value |
|---|---|
| Path: | /power?state=on|off|toggle |
| Method: | POST |
Since 0.7.0. Answers with the result rather than a bare "accepted":
{ "state": "off", "ok": true, "method": "device_admin", "screenOn": false }HTTP 200 when it was carried out, 501 when this device has no way to do it, 400 on a missing or
unknown state. (screenOn in the reply can lag one poll behind on state=on: the wake activity is
still starting up.)
On needs nothing: PiPup launches an invisible activity with setTurnScreenOn(true), which is the
supported way to wake a device. On HDMI-CEC setups waking the box also switches the TV to its input.
Off is the one capability that can genuinely be missing, because no sideloaded app may put a
device to sleep on its own. There are two routes, and PiPup uses whichever is granted (device admin
first). Grant one once, over adb — install.sh --power / --accessibility do exactly this:
# route 1 (preferred): device admin. Only asks for force-lock, nothing else.
adb shell dpm set-active-admin nl.rogro82.pipup/.AdminReceiver
# route 2: accessibility fallback, for devices without the device-admin feature
adb shell settings put secure enabled_accessibility_services \
nl.rogro82.pipup/nl.rogro82.pipup.PiPupAccessibilityService
adb shell settings put secure accessibility_enabled 1
Route 2 exists because a fair number of Android TV boxes ship without the device-admin feature at
all (dumpsys device_policy shows mHasFeature=false). Confusingly, dpm set-active-admin still
prints Success there while nothing is registered — so trust /state, not dpm (the installer
scripts verify it that way and tell you to switch routes). Verified on hardware:
| Device | Android | Screen on | Screen off |
|---|---|---|---|
| Fire TV stick (AFTKA) | 9 (Fire OS) | ✓ | ✓ device admin |
| Nokia Streaming Box 8010 | 14 | ✓ | ✓ accessibility (no device-admin feature) |
| TCL Google TV | 11 | ✓ | ✓ accessibility (no device-admin feature) |
Two things seen while testing: a wake request that arrives within a few seconds of putting the
device to sleep can be ignored while the sleep transition is still completing (a second call works),
and on Android 13+ an accessibility service enabled over adb can be revoked again by the system — the
switch's can_sleep attribute (and /state) show that immediately.
enabled_accessibility_services, keep the existing value (colon-separated) —
overwriting it disables other accessibility services, such as Projectivy Launcher's. The installer
scripts append; the snippet above only holds for a device with none enabled.
adb install -r,
including an update) drops the app out of the enabled list, and screen-off silently stops working —
/state reports power.canSleep: false from then on. Re-run install.sh --accessibility, or include
the flag in the install itself. The device admin route does not have this problem.
The accessibility service declares no event types and does not retrieve window content: it is bound
purely so GLOBAL_ACTION_LOCK_SCREEN can be called, and reads nothing from your screen.
/state publishes the capability so a client can hide a button it cannot honour:
"power": { "canWake": true, "canSleep": true, "sleepMethod": "device_admin" }| Property | Value |
|---|---|
| Path: | /permissions/fix[?what=overlay|install|admin|accessibility|next] |
| Method: | POST |
Since 0.8.0. Puts the screen that grants a permission in front of the user and wakes the TV first.
Without what it opens PiPup's own status screen, which lists every permission with its own Fix
button; what=next jumps to the first missing one.
{ "what": "overlay", "ok": true, "granted": false, "adb": null }The app still cannot grant anything itself — these are app-ops, which only shell or the system may set. What it can do is walk someone holding a remote to the exact spot, which is the part that was missing.
Whether that spot exists differs per device, and asking is not enough: every Android build must
resolve these intents to pass Google's compatibility suite, so a plain "is there an activity for
this?" says yes even where nothing happens. Fire OS answers with CTSDummyIntentHandler, Google TV
with frameworkpackagestubs.Stubs. PiPup treats those placeholders as absent, because a button that
visibly does nothing is worse than no button: /state then reports the permission as not fixable,
/permissions/fix answers 501, and both the TV screen and the reply carry the adb command.
Measured on hardware:
| Fire OS 9 | Google TV 11 | Android 14 | |
|---|---|---|---|
| Overlay permission | adb only | ✓ on screen | ✓ on screen |
| Self-update permission | ✓ on screen | ✓ on screen | ✓ on screen |
| Device admin | adb only | not supported by the platform | not supported by the platform |
| Accessibility | adb only | adb only | ✓ on screen |
/state publishes this as permissions.fixable, so a controller can show a button only where it
leads somewhere.
A settings screen that opens but whose toggle will not stick is a third case, distinct from a
missing screen. Some devices lock a permission at system level for sideloaded apps — Samsung's Auto
Blocker, or a TCL that keeps "install unknown apps" off — which shows up as an app-op stuck in the
errored or ignored state (/permissions/diagnose reports opModes). Since 0.11.1 PiPup treats
such an op as not fixable on screen: fixIntent returns nothing, the status screen shows the adb
command with a "this TV blocks it from its settings screen" note, and POST /permissions/fix answers
501 with that reason and the command — rather than opening a screen where nothing happens. A
neutral default op (the normal case) still gets the on-screen Fix button.
Without the overlay permission, the fix screen cannot be opened from Home Assistant. From Android
10 on, starting an activity from the background is blocked unless the app is exempt, and holding
SYSTEM_ALERT_WINDOW is one of the exemptions — while a foreground service is
explicitly not. So the
one permission you most want a button for is the one whose absence takes the button away. A blocked
launch does not even throw: the platform drops it silently.
PiPup therefore checks up front and answers 501 with
reason: "Android blocks starting an activity from the background…" instead of reporting success and
doing nothing. The way out is a visible window of the app: open PiPup on the TV (from the launcher,
or by tapping its ongoing notification — a notification tap is another exemption) and press the Fix
button on its status screen, which is running in the foreground and therefore allowed. Or grant it over
adb and never think about it again.
Once the overlay permission is granted, everything else — install, accessibility — opens fine from
Home Assistant with the app in the background.
| Property | Value |
|---|---|
| Path: | /permissions/diagnose |
| Method: | GET (or POST) |
Since 0.9.0, and the first thing to attach to a bug report — no adb or logcat needed:
{
"sdk": 30,
"device": { "model": "Smart TV", "manufacturer": "TCL", "android": "11" },
"backgroundLaunchExempt": true,
"activityVisible": false,
"deviceAdminSupported": false,
"screens": {
"overlay": {
"granted": true,
"action": "android.settings.action.MANAGE_OVERLAY_PERMISSION",
"resolvedActivity": "com.android.tv.settings.device.apps.specialaccess.SystemAlertActivity",
"placeholder": false,
"fixable": true,
"adb": "adb shell appops set nl.rogro82.pipup SYSTEM_ALERT_WINDOW allow"
}
},
"lastFix": { "what": "overlay", "ok": false, "activity": null, "error": "…", "secondsAgo": 42 }
}Read it as: resolvedActivity null means nothing handles that intent or the platform hides it from
the app; placeholder: true means a vendor stub answered and a button would do nothing;
backgroundLaunchExempt: false means no launch can happen at all right now (see above); and lastFix
says how the previous attempt actually ended. The Home Assistant integration includes this block in its
diagnostics download.
Note on resolvedActivity: from Android 11 on, resolveActivity() is a query and queries are
filtered by package visibility, while startActivity() is not — "I cannot see it" is not "it is not
there". The app declares these intents in <queries> so it can see them, and treats an unresolved
intent as worth trying rather than impossible. A device whose forceQueryable list omits Settings
would otherwise hide a perfectly working button (adb shell dumpsys package queries shows that list). Note deviceAdmin: null on the last two: those platforms have no device
administration at all (hasSystemFeature(FEATURE_DEVICE_ADMIN) is false) — a different answer from
"not granted", and worth distinguishing because dpm set-active-admin reports Success there
anyway.
| Property | Value |
|---|---|
| Path: | /settings[?updateChecks=true|false] |
| Method: | GET or POST |
Since 0.24.0. Persistent device settings, kept across restarts and updates. updateChecks (default
true) switches the twice-daily GitHub release check on or off: a TV kept off the internet on purpose
then makes no outbound calls. Both methods answer the current values, e.g. {"updateChecks":false}.
Two more keys:
webhook: anhttp(s)URL. The app POSTs its/stateJSON plus aneventfield to it on every change:popup_shown(withshownId),popup_replaced(withshownIdandreplacedId, both the id that was redrawn),popup_removed(withreasonexpired / cancelled / button / back / watchdog, andremovedId),started,screen_on,screen_off,permissions, andsettingsright after the webhook is set. A failed POST is retried once after 2 s. Empty turns push off./settingsanswers only whether one is set: the URL is the controller's secret./state.pushshows the last push result.updateSource:github:<owner>/<repo>(defaultgithub:mhoogenbosch/PiPup), or anhttp(s)folder URL holdingreleases.json(GitHub's/releasesanswer saved as is) and the APKs under their release names. The first release that is neither draft nor prerelease wins. Empty resets to the default. A different source cannot slip in a foreign app: Android only installs an update signed with the same key as the installed PiPup.
Like the rest of the API, /settings has no authentication: anything on the LAN can change it. Keep
the TVs on a network you trust.
| Property | Value |
|---|---|
| Path: | /state |
| Method: | GET (or POST) |
Returns the current state as JSON:
{
"app": "PiPup",
"version": "0.7.0",
"id": "6f1f9c1e-4a3f-4a44-9d2c-6f1f9c1e4a3f",
"name": "FireTV Veranda",
"visible": true,
"screenOn": true,
"popupsShown": 12,
"watchdogCleanups": 0,
"uptime": 86400,
"device": { "model": "AFTKA", "manufacturer": "Amazon", "android": "9" },
"popup": { "id": "doorbell", "duration": 0, "indefinite": true, "elapsed": 42 },
"popups": [
{ "id": "fantasy", "position": "TopRight", "duration": 0, "indefinite": true, "elapsed": 900, "media": "web" },
{ "id": "doorbell", "position": "BottomRight", "duration": 0, "indefinite": true, "elapsed": 42, "media": "video" }
],
"power": { "canWake": true, "canSleep": true, "sleepMethod": "device_admin" },
"permissions": {
"overlay": true, "installPackages": true, "autoStart": null,
"deviceAdmin": true, "accessibility": false, "complete": true,
"fixable": { "overlay": false, "install": true, "admin": false, "accessibility": false }
}
}Since v0.24.0 popups lists every popup on screen in stack order (the last one is on top; id is
null for the popup sent without an id). visible is true while any popup is up, and popup is the
one on top, so a caller written for one popup at a time keeps working.
Since v0.7.0 permissions reports what the app was actually granted, and power what it can do with
the screen (see Screen on/off). overlay: false is the one to watch: popups are then
accepted with HTTP 200 and stay invisible. autoStart is TCL's vendor app-op and is null on every
device that does not have it — that is "not applicable", not a problem. complete is the short answer
to "can this device show popups at all".
Since v0.5.0 the response also contains lastPopup: the parameters of the last received popup
(id, position, duration, muted, media type/size, tts, buttons, secondsAgo) — it survives dismiss/expiry,
so you can always verify what your home-automation actually sent. The same block is rendered live on
the app's status screen on the TV.
Since v0.2.3 /state also reports whether the screen is on/interactive (screenOn), the number of
popups shown since the service started (popupsShown), the service uptime in seconds and basic
device info — all surfaced as entities by the Home Assistant integration. Since v0.2.5 it also
reports a stable device id (generated once, survives app updates) and the device name; since
v0.2.6 watchdogCleanups counts how often the overlay watchdog had to force-remove a stale popup.
Since v0.2.5 the app advertises itself over mDNS/zeroconf as _pipup._tcp (port 7979) with TXT
records id (the stable device id), name and version, enabling automatic discovery.
CI builds an APK on every push (see .github/workflows/build.yml); tagged releases get the APK
attached automatically. Locally: JDK 17 + Android SDK 35, then ./gradlew assembleDebug.
MIT for the work in this fork. The original PiPup by rogro82 was published without a license; its code remains his.
See CHANGELOG.md — every version also has a GitHub release with the full story and the APK.
