Generated by aurora@aurora-smart-home (ha-integration-dev skill)
Control PiPup popups on Android TV / Fire TV devices from Home Assistant — including showing a camera stream for as long as there is motion.
Requires the PiPup fork APK on the TV
(the original Play Store version lacks the /state endpoint and indefinite popups).
Contributors: David Bebawy (davbebawy) built push instead of poll, overlays, several popups at once, see-through popups and the update source option (1.22.0); andrewm1205 added the top/bottom center positions (1.19.0).
-
Config flow per TV (host + port) with automatic mDNS discovery (app ≥ 0.2.5 advertises
_pipup._tcp); discovered TVs carry a stable device id, so the config entry follows the TV across DHCP address changes (existing host:port entries migrate automatically on first contact). Each TV becomes a device with the entities below. The device hangs under the TV's own device from the Android TV Remote or Android Debug Bridge integration on the same IP (remote preferred); pick another one under Configure → Parent device (TV). Each TV device has:- Popup binary sensor (on = a popup is visible; attributes:
popup_id,duration,indefinite,elapsed) - Screen binary sensor (TV screen on/interactive — requires app ≥ 0.2.3)
- Connectivity binary sensor — FireTV sticks cut their network in standby; entities now stay available with their last state (popup/screen report unknown) and this sensor tells you whether the TV currently answers, so automations can trigger on its off→on edge
- Notification notify entity — the TV works as a standard
notify.send_messagetarget (text-only popups honoring the per-device defaults) - Current popup sensor (the visible popup id) and Popups shown counter
- Default position select — the position used when
pipup.showis called without one - Dismiss popup button
- PiPup app update entity — checks the fork's GitHub releases and, with app >= 0.6.0, the Install button makes the TV update itself (silent on Android 12+, system confirmation on older devices)
- Screen switch (app ≥ 0.7.0) — turns the TV on (wake) and off (standby) straight through
PiPup, so this no longer needs a second ADB/CEC integration. Attributes
can_sleepandsleep_methodsay what the device can actually do: turning on always works, turning off needs a one-time adb grant on the TV (see screen on/off) - TV media player (app ≥ 0.29.0) — a remote control without adb: the installed apps as sources (select one to start it), play/pause/stop/next/previous as media keys to whatever app is playing, volume up/down/mute, a volume slider where the box owns its volume, and on/off through the screen routes. See TV control
- Permission problem diagnostic binary sensor (app ≥ 0.7.0) — on when the app misses a
permission it needs, with each grant as an attribute. Without the overlay app-op the TV accepts
every popup with HTTP 200 and shows nothing, and
adb install -rresets it, so this also raises a repair — one that can now fix itself (app ≥ 0.8.0): pressing Submit puts the system's permission screen on the TV, where it takes one press on the remote. Thefixable_on_tvattribute lists which permissions that works for on this device - Permission screen button (app ≥ 0.8.0) — shows PiPup's own permission overview on the TV, with a Fix button next to everything that is missing
- Fix self-update permission button (app ≥ 0.11.1) — puts the "install unknown apps" screen on the TV for the permission the self-updater needs. Neither HA nor the app can grant it (it is an app-op), so on a device that blocks it outright the button's error carries the exact adb command instead of pretending the press worked
- App uptime diagnostic sensor and a diagnostics download — which, with app ≥ 0.9.0, includes the app's own permission diagnosis (which activity handles each settings screen, whether it is a vendor placeholder, whether a background launch is allowed, how the last attempt ended). That file is what to attach to a "the fix button does nothing" report
- Popup binary sensor (on = a popup is visible; attributes:
-
Actions
pipup.launch_app(an app by package, or a deep link) andpipup.send_key(media keys, home, back) — app ≥ 0.29.0 -
Camera wall in
pipup.showwithgrid(app ≥ 0.29.0): up to nine WHEP streams in one popup, or full screen. See Camera wall -
Blueprints for an info card and an "appliance done" card — see Blueprints
-
Action
pipup.show— title/message/media popup with all PiPup fields, plus:duration: 0→ popup stays until dismissed or replacedpopup_id→ re-sending the same id+content only extends the timer (stream keeps playing, no flicker)camera_entity→ shows the camera without any URLs;camera_modepicks mjpeg (default — rendered in a WebView, audio-free), stream (HLS via ExoPlayer) or snapshot. With app ≥ 0.16.0stream(and anyvideo_url, incl.rtsp://) renders into a TextureView, so it shows over video the TV is already playing; on older apps it used aVideoViewthat could freeze or black out concurrent playback — update the app rather than avoidingstream. HLS runs a few seconds behind live. With go2rtc (stand-alone or in Frigate) usewhep_url+poster_urlinstead (see below and Which camera route?): faster and steadier than anycamera_modepositionis optional — omitted means the device's configured default position. Choices:top_right,top_left,bottom_right,bottom_left,center,top_center/bottom_center(centered along the top or bottom edge, app ≥ 0.22.0) andcenter_left/center_right(centered along the side edge, app ≥ 0.26.0) — the full 3×3 grid. On an app too old for a position the action reports an error for that TV instead of sending a value the app would reject.top_right,center_rightandbottom_rightstack along one side when several popups (each with its ownpopup_id) are shown at once.muted: trueby default (requires app ≥ 0.2.4) — audio in a popup can freeze video playback on some TVs; passmuted: falsewhen you do want sound- per-device defaults for everything — duration, position, muted, media size, colors and text sizes are configurable per TV in the integration options (Settings → Devices & Services → PiPup → Configure); action fields act as one-off overrides
tts→ text spoken aloud on the TV when the popup appears (app ≥ 0.2.5); optionaltts_languagetakes a BCP-47 tag such asnl-NL(device default when omitted)padding→ outer margin of the popup around its content in px (app ≥ 0.19.1); 0 = near-borderlessbutton_size→ button text size in sp, padding scales along (app ≥ 0.19.0); also a per-device default — three buttons no longer dictate a wide popupanimation→fade/slide_left/slide_right/slide_top/slide_bottomentrance animation, reversed on natural expiry (app ≥ 0.19.0); also a per-device default, andnonein a call suppresses that default once. An update-in-place of the same popup never re-animatessound→ notification sound when the popup is newly shown (app ≥ 0.18.0):"default"plays the app's built-in chime, any other value is a URL to an audio clip;sound_volume0–1. Not replayed on an update-in-place, so a re-notified motion popup stays quiet. Also available as a per-device default (empty = none); an explicit emptysoundin a call suppresses that default once. Likettsthis opens an audio path — some Fire TVs renegotiate HDMI audio brieflydismiss_screensaver→ the app ends an active screensaver / ambient mode before showing the popup (app ≥ 0.18.0, default on, also a per-device default), because on several Android builds the screensaver layer covers app overlays and Android 12+ lets it hide them. Setfalseto keep the screensaver running (the popup may then stay invisible on such TVs)buttons→ up to 3 remote-operable buttons on the popup (app ≥ 0.3.0): OK activates, BACK dismisses. A press fires thepipup_buttonevent (withpopup_id,button,label,device_id) via an automatically registered local-only webhook — no configuration needed; see the doorbell example belowshow_progress→ animated countdown bar for finite durations (app ≥ 0.3.0)urgency→info/warning/criticalcolored border presets (app ≥ 0.3.0)border_color,border_width,corner_radius→ style the frame yourself (app ≥ 0.7.0). Each overrides its part of theurgencypreset, so they combine:urgency: criticalwithborder_width: 2is a thin red border, andborder_width: 0drops the preset's frame.corner_radiusalso works without a border. Sizes are in pixels, and all three are available as per-device defaults toowhep_url→ a WebRTC stream played straight from a WHEP endpoint, e.g. go2rtchttp://<go2rtc>:1984/api/webrtc?src=<camera>(app ≥ 0.25.0). Same live stream as go2rtc's player page inweb_url, without loading that page: on a Fire TV it showed the first frame after 2.3 s on average (2.2–2.5) against 3.4 s (2.0–6.6) for the page.muteddefaults to true, andposter_url/transparentwork as forweb_url. An older app gets an error from the action instead of a failing popupposter_url→ a still image shown over avideo_url/web_url/whep_urlstream the moment the popup opens, faded out on the stream's first frame (app ≥ 0.17.0) — so a live popup never opens as an empty box while RTSP connects or the WebView starts.camera_entitypopups get this automatically (mjpegandstreammodes): a signed snapshot of the same camera is sent as postericon→ an image URL shown beside the title/message (app ≥ 0.13.0), notification-style, withicon_position(leftdefault /right) andicon_width(pixels). Position and width also have per-device defaults; the icon URL itself is per call. Loaded like any other image, so a localhttp://homeassistant.local:8123/local/icons/…path works
-
Action
pipup.dismiss: remove the popup with a givenpopup_id, else the popup shown without an id;all: trueremoves every popup. The Dismiss popup button removes every popup.Upgrading from ha-pipup ≤ 1.21 / app ≤ 0.23: a
pipup.dismisswithoutpopup_idused to remove whatever was on screen. Since 1.22.0 / app 0.24.0 it only removes a popup that was shown without an id — a popup shown withpopup_idstays up, and nothing reports an error (the app answers200 no popup without an id). Indefinite camera popups (duration: 0) then never go away. Pass the samepopup_idas inpipup.show, orall: trueto clear the TV. -
Several popups at once (app >= 0.24.0): each
popup_idis its own popup on the TV, so a doorbell popup opens beside an overlay instead of replacing it. Popups without an id share one slot.bring_to_frontinpipup.showredraws a popup that is already up on top of the others. The Current popup sensor shows the one on top, with every id in itspopupsattribute; Popups on screen counts them. -
Stacking (app >= 0.27.0):
stack: verticalorstack: horizontalinpipup.showlines a popup up with the other stacking popups at the same position, so two camera popups on the right edge sit one above the other instead of on top of each other. The rest move up when one closes. Set a per-TV default under Configure (Default stacking);stack: noneoverrides it for one call. An older app ignores the field and the popups overlap as before. -
Camera popup look and button style (app >= 0.28.0, contributed by @pelkiegr):
live_badge(+live_badge_color,live_badge_position) shows a "Live" pill once the stream plays,media_full_bleedruns the media edge to edge, andbuttons_inlineputs the buttons beside the text.button_color,button_focus_color,button_text_color,button_border_colorandbutton_corner_radiusrestyle the buttons. A per-callmedia_heightnow also fixes a video popup's box until the first frame (the per-TV default is deliberately not sent for video). Everything is sent only when set; an older app ignores it. -
Push instead of poll (app >= 0.24.0): at setup every TV gets its own local-only webhook, and the app POSTs its state there on every change. Popup, screen and overlay entities follow within milliseconds instead of at the next 15 s poll. A 60 s heartbeat poll remains, because a TV that falls asleep or drops off the network cannot push that. Each push also fires a
pipup_eventevent:event(popup_shown,popup_replaced,popup_removed,started,screen_on,screen_off,permissions,settings),reason(for a removal:expired,cancelled,button,back,watchdog),popup_id,shown_id,removed_id,replaced_id,popup_ids,device_id(the HA device),pipup_idanddevice_name. An older app keeps the timed poll. The push needs an internal Home Assistant URL the TV can reach (Settings → System → Network). -
Sync button and action
pipup.sync: read the TV's state now and send the webhook again (after a reinstall of the app, for example; the integration also does this by itself when the app reports it has no webhook). -
pipup.showfieldsopacity(0..1, the whole popup) andtransparent(web_urlonly: a page withhtml, body { background: transparent }floats over the TV picture) (app >= 0.24.0). -
Option App update source:
github:owner/repo(defaultgithub:mhoogenbosch/PiPup), a folder URL withreleases.jsonand the APKs (a LAN mirror for TVs without internet), oroff. The app on the TV gets the same setting, so its own twice-daily check agrees;offalso drops the update entity. -
Action
pipup.fix_permission(app ≥ 0.8.0) — put a permission screen on the TV: the app's own overview, the first missing permission, or a specific one. Neither HA nor the app can grant these (they are app-ops, i.e. shell/system territory), but walking someone to the exact screen beats telling them to go find an adb prompt. The TV is woken first. Where a device has no such screen — Fire OS answers those intents with placeholders that do nothing — or where the device blocks the permission at system level (an app-op stuckerrored/ignored, e.g. a TV that locks "install unknown apps" for sideloaded apps; app ≥ 0.11.1), the action fails with the adb command in its message instead of pretending it worked.⚠️ The overlay permission itself cannot be fixed remotely. Android blocks background activity starts unless the app holdsSYSTEM_ALERT_WINDOW, so the one permission you most want this for is the one whose absence takes it away — and a blocked launch is dropped silently by the platform. App ≥ 0.9.0 says so in the error instead of failing quietly. The way out: open PiPup on the TV (from the launcher, or by tapping its ongoing notification) and use the button on its status screen, or grant it over adb. Everything else opens fine with the app in the background.
- HACS → Integrations → Custom repositories
- Add
https://github.com/mhoogenbosch/ha-pipupas Integration - Install "PiPup" and restart Home Assistant
- Copy
custom_components/pipupto yourcustom_components/ - Restart Home Assistant
TVs running the fork app ≥ 0.2.5 are discovered automatically — they show up under Settings → Devices & Services as a discovered PiPup device; press Configure, optionally set a name and a name suffix, done. Because entries key on the app's stable device id, they keep working when the TV gets a different DHCP address.
Manual fallback (older app, or discovery blocked between VLANs): Settings → Devices & Services → Add Integration → PiPup → enter the TV's IP and port (default 7979). Adding a TV that is already configured is also the way to correct its address: the flow recognises the device id and moves the existing entry to the new IP instead of creating a second one.
Why an address can go wrong (fixed in 1.9.1): PiPup's mDNS service is registered under the device hostname, and Android TVs collide there — several sticks claim
Android.local,Android-4.local, … So resolving one TV's service could hand back another TV's address, and the entry would quietly start polling (and showing popups on) the wrong TV, with its own correct device id in the announcement. Since 1.9.1 a discovered address is verified against the device before an existing entry is moved, and a mismatch aborts withaddress_mismatch.
Per-TV popup defaults (position, duration, muted, sizes, colors, border styling) live under Configure on each entry; action fields override them per call. Note the difference for the two numeric border fields: empty means "no default", while 0 is a real value (no border / square corners).
The Screen switch needs app ≥ 0.7.0. Turning the TV on works out of the box. Turning it off
is something no sideloaded app may do on its own, so grant one of the two routes once per TV — the
installer script in the PiPup release does this with --power:
adb shell dpm set-active-admin nl.rogro82.pipup/.AdminReceiverDevices without the device-admin feature (a fair number of Android TV boxes; dpm still prints
Success there while registering nothing) use the accessibility fallback instead — see the
PiPup readme. The switch reports which route
is active in its sleep_method attribute, and turn_off on a TV without any route raises an error
naming the fix rather than failing silently.
Because the app derives the screen state from PowerManager.isInteractive, which lags a poll behind
a fresh wake, the switch trusts its own last command for up to 20 seconds and schedules an extra
refresh — so it does not visibly bounce back after you flip it.
With app ≥ 0.29.0 every TV gets a media player entity and two actions. Nothing to grant: it uses the same "display over other apps" permission popups already need.
| How | |
|---|---|
| Start an app | media_player.select_source with the app's name (the source list is the installed TV apps), or pipup.launch_app with package |
| Open a link | pipup.launch_app with uri (and optionally package to force the app) |
| Play/pause, next, … | the media player's transport buttons, or pipup.send_key |
| Home / back | pipup.send_key — needs the accessibility service on the TV (install.sh --accessibility) |
| Volume | the media player's volume slider, up/down and mute |
| On / off | the media player's power button (off needs the screen-off route, as for the Screen switch) |
- action: pipup.launch_app
target:
entity_id: media_player.pipup_living_room
data:
uri: https://www.youtube.com/watch?v=dQw4w9WgXcQ
- action: pipup.send_key
target:
entity_id: media_player.pipup_living_room
data:
key: play_pauseWhat it cannot do: PiPup does not see which app is in front or what is playing (that needs usage
access, a grant a TV has no screen for). The media player's state is therefore the screen (on/off),
its source is only the app PiPup itself started last, and play/pause sends the toggle key
because PiPup does not know which of the two applies. On a box whose volume belongs to the TV set or
amplifier over HDMI-CEC (volume_fixed: true) the slider is hidden; up/down and mute are passed on.
grid (app ≥ 0.29.0) puts up to nine WHEP streams in one popup. Each tile is a WHEP URL, or a
mapping with whep_url, label and poster_url. media_width/media_height size the whole grid
(default 1280×720); grid_fullscreen: true fills the screen, centred and edge to edge unless you give
a position or padding yourself. grid_columns overrides the automatic layout (2 columns up to four
tiles, 3 above) and grid_labels: false hides the captions.
fullscreen: true (app ≥ 0.29.1) works on any popup: the window covers the whole screen, status and
navigation bars included, with the popup centred. On a TV that looks the same as before; on a device
with system bars (a tablet, an intercom panel) it is what lets a screen-sized page reach the edges.
grid_fullscreen sets it for you.
- action: pipup.show
target:
entity_id: binary_sensor.pipup_living_room_popup
data:
popup_id: camera_wall
duration: 120
grid_fullscreen: true
grid:
- whep_url: http://192.168.1.20:1984/api/webrtc?src=doorbell
label: Front door
poster_url: http://192.168.1.20:5000/api/doorbell/latest.jpg
- whep_url: http://192.168.1.20:1984/api/webrtc?src=driveway
label: Driveway
- whep_url: http://192.168.1.20:1984/api/webrtc?src=garden
label: Garden
- whep_url: http://192.168.1.20:1984/api/webrtc?src=shed
label: ShedAll tiles must come from the same go2rtc as the first one (go2rtc sends no CORS headers by default).
Every tile is decoded on the TV, so on a small stick use the cameras' substreams. Close the wall with
pipup.dismiss and the same popup_id.
Two blueprints ship with the integration. Import them with the buttons, or paste the URL under Settings → Automations & scenes → Blueprints → Import blueprint.
One script with the look of your info cards set once — TVs, position, default duration, stacking,
chime, countdown bar. Call it with just the text that changes: title, message, and optionally
icon (a full image URL), urgency (info/warning/critical), duration and card_id (the same id
replaces a card that is still up instead of adding a second one). The examples below call it as
script.tv_info_card.
The weather when someone turns on the TV:
triggers:
- trigger: state
entity_id: switch.pipup_living_room_screen
to: "on"
actions:
- action: script.tv_info_card
data:
card_id: weather
title: Weather
message: >-
{{ state_attr('weather.home', 'temperature') }} °C,
{{ states('weather.home') | replace('-', ' ') }}Today's calendar in the morning:
triggers:
- trigger: time
at: "07:30:00"
actions:
- action: calendar.get_events
target:
entity_id: calendar.family
data:
duration: {hours: 16}
response_variable: agenda
- if: "{{ agenda['calendar.family'].events | count > 0 }}"
then:
- action: script.tv_info_card
data:
card_id: agenda
title: Today
duration: 45
message: >-
{% for e in agenda['calendar.family'].events %}
{{ as_timestamp(e.start) | timestamp_custom('%H:%M') if 'T' in e.start else 'All day' }}
· {{ e.summary }}
{% endfor %}A card on the TVs when the washing machine, dryer or dishwasher has finished, from its power meter:
above Running above (default 10 W) for a couple of minutes marks it as running, then below Done
below (3 W) for Done after (5 min) shows the card. Give it a toggle helper (input_boolean) of its
own: that remembers "running" across a Home Assistant restart, and a 5-minute check catches a finish
whose quiet period was interrupted by one. Optional: only show the card when a TV screen is on.
An overlay is a web page pinned over the TV picture, switched from a dashboard. A TV can have several (an agenda column, a score strip).
- Configure the TV entry and fill Overlay pages, one line each:
Name | URL. - Add overlay on the integration page and give it a name. Its popup id is the name as a slug, fixed at creation.
- The overlay's device has a Show switch, its settings as entities, and a Status sensor.
The Show switch follows the TV: if the overlay times out or someone closes it, the TV pushes that and the switch goes off. Status says which. With app 0.24.0 or newer other popups open beside the overlay and leave it on; with an older app another popup replaces it. Change a setting while the overlay is up and it is redrawn after 1 s, in its place in the stack, or on top of the other popups with Redraw on top on.
A see-through page needs html, body { background: transparent } and the Transparent switch
on; Page opacity fades the whole popup instead.
For live camera popups (doorbell, motion) the recommendation is whep_url + poster_url
(app ≥ 0.25.0), with go2rtc as the source:
whep_url: "http://<go2rtc>:1984/api/webrtc?src=<camera>"
poster_url: "http://<frigate>:5000/api/<camera>/latest.jpg" # still on screen at onceMeasured on a Fire TV, first live frame: whep_url 2.3–2.5 s on average, go2rtc's player page in
web_url 3.4–6.4 s, Frigate MJPEG (web_url → /api/<camera>?fps=5) 0.9 s. MJPEG starts fastest but
runs at detect fps and 2–3 s behind reality; WHEP is full frame rate and under a second behind. With
the poster the popup shows a picture at once either way. camera_entity (no go2rtc needed) is the
fallback. Full table and the reasoning: the
app README.
Poster shows, video never comes, on one TV only? Some Android TV boxes (a Nokia 8010 here) have a 256 KB UDP receive buffer, and WebRTC delivers each keyframe as one burst. Keyframes above ~220 KB (a 5–6 Mbit/s main stream at 1 s keyframe interval) then never decode on that box, while a Fire TV plays them fine. Lower the camera's main-stream bitrate until its keyframes fit, or point that TV at the sub-stream; how to measure is in the app README.
Real-world recipes from the household this integration was built for. All popup styling (position, colors, size) falls back to the per-device defaults, so the calls stay short.
One script pops a message on every TV — straight through games and headphone sessions — and speaks it out loud (app ≥ 0.2.5). Unreachable/sleeping TVs are skipped silently.
script:
announce:
alias: House announcement
fields:
message: {required: true}
sequence:
- action: pipup.show
continue_on_error: true # sleeping TVs must not abort the rest
target:
entity_id:
- binary_sensor.pipup_living_room_popup
- binary_sensor.pipup_kids_room_popup
- binary_sensor.pipup_bedroom_popup
data:
popup_id: announce
title: "📢 Announcement"
message: "{{ message }}"
duration: 15
tts: "{{ message }}"
tts_language: nl-NL
muted: falseWire it to a dashboard button with tap_action → perform-action: script.announce,
data: {message: "Dinner is ready!"} — add a confirmation: so nobody summons the
whole house by accident.
The message field takes Jinja, so a "mini dashboard" is just a multi-line template.
With show_progress: true (app ≥ 0.3.0) the popup shows a countdown bar.
action: pipup.show
target:
entity_id: binary_sensor.pipup_living_room_popup
data:
popup_id: energy
duration: 25
position: center
title: "⚡ Energy & car"
show_progress: true
message: >-
{% set net = states('sensor.grid_power') | int(0) %}
☀️ Solar: {{ states('sensor.pv_power') | int(0) }} W
🏠 House: {{ states('sensor.house_power') | int(0) }} W
🔌 Grid: {{ net | abs }} W {{ 'import' if net > 0 else 'export' }}
🚗 Battery: {{ states('sensor.car_battery') | int(0) }}%Buttons (app ≥ 0.3.0) are operated with the TV remote: OK activates, BACK dismisses.
A press fires the pipup_button event, so the acting automation can (and should) filter on
both the button id and the device id — only the intended TV can open the door.
# 1) the doorbell automation shows the popup with a button
- action: pipup.show
target:
entity_id: binary_sensor.pipup_living_room_popup
data:
popup_id: doorbell
duration: 45 # finite: never leave an undismissable popup
title: Front door
tts: "Someone is at the door"
whep_url: "http://go2rtc.local:1984/api/webrtc?src=doorbell" # app >= 0.25.0
poster_url: "http://frigate.local:5000/api/doorbell/latest.jpg"
media_width: 720
media_height: 540
buttons:
- id: unlock_front_door
label: "🔓 Open the door"
# 2) a second automation acts on the press
triggers:
- trigger: event
event_type: pipup_button
event_data:
button: unlock_front_door
device_id: "1d91b16a-…" # the app's stable id — check the Diagnostics download
actions:
- action: lock.open
target:
entity_id: lock.front_door
- action: notify.mobile_app_phone # audit trail
data:
message: "Front door opened via the living-room TV"triggers:
- trigger: calendar
entity_id: calendar.family
event: start
offset: "-00:15:00"
conditions:
- "{{ not trigger.calendar_event.all_day }}" # all-day events would pop at 23:45
actions:
- action: pipup.show
target:
entity_id: binary_sensor.pipup_living_room_popup
data:
popup_id: agenda
duration: 30
show_progress: true
title: "📅 Up next ({{ trigger.calendar_event.start | as_timestamp | timestamp_custom('%H:%M') }})"
message: "{{ trigger.calendar_event.summary }}"triggers:
- trigger: state
entity_id: binary_sensor.driveway_motion
to: "on"
id: motion_on
- trigger: state
entity_id: binary_sensor.driveway_motion
to: "off"
for: "00:00:15"
id: motion_off
actions:
- choose:
- conditions:
- condition: trigger
id: motion_on
sequence:
- action: pipup.show
target:
entity_id: binary_sensor.pipup_living_room_popup
data:
title: Motion on the driveway
duration: 0 # until dismissed
popup_id: driveway
camera_entity: camera.driveway # camera_mode defaults to mjpeg (safe next to live TV)
# with go2rtc, faster and smoother (app >= 0.25.0), instead of camera_entity:
# whep_url: "http://go2rtc.local:1984/api/webrtc?src=driveway"
# poster_url: "http://frigate.local:5000/api/driveway/latest.jpg"
- conditions:
- condition: trigger
id: motion_off
sequence:
- action: pipup.dismiss
target:
entity_id: binary_sensor.pipup_living_room_popup
data:
popup_id: driveway # required: without it only a popup *without* id is removedWhen you show indefinite (duration: 0) popups, make the automation mode: queued and put
continue_on_error: true on the pipup actions — with the default mode: single a dismiss that
fires while a show is still running is silently dropped, leaving the popup on screen.
Whoever is watching gets the picture of every outside camera at once, and the film waits. The wall
closes by itself after two minutes (duration), so a restart or a missed second trigger cannot leave
it on screen.
triggers:
- trigger: state
entity_id: binary_sensor.doorbell_pressed
to: "on"
not_from: [unavailable, unknown]
actions:
# pause, not play/pause: running this twice must not resume playback
- action: media_player.media_pause
continue_on_error: true
target:
entity_id: media_player.pipup_living_room
- action: pipup.show
continue_on_error: true
target:
entity_id: binary_sensor.pipup_living_room_popup
data:
popup_id: doorbell_wall
duration: 120
grid_fullscreen: true
grid:
- whep_url: http://go2rtc.local:1984/api/webrtc?src=doorbell
label: Front door
poster_url: http://frigate.local:5000/api/doorbell/latest.jpg
- whep_url: http://go2rtc.local:1984/api/webrtc?src=driveway
label: Driveway
poster_url: http://frigate.local:5000/api/driveway/latest.jpg
- whep_url: http://go2rtc.local:1984/api/webrtc?src=garden
label: Garden
- whep_url: http://go2rtc.local:1984/api/webrtc?src=side
label: Side path
mode: single # a second press within the same run adds nothingThe media key reaches the app that holds the media session; an app without one (some live-TV apps) keeps playing. Point the tiles at substreams on a small streaming stick.
A script, so a dashboard button (tap_action: perform-action) or a voice command can call it. Starting
the app wakes a sleeping screen first.
script:
living_room_youtube:
alias: YouTube on the living-room TV
mode: single
sequence:
- action: media_player.select_source
target:
entity_id: media_player.pipup_living_room
data:
source: YouTube # the app's name as in the media player's source list
- action: media_player.volume_set
target:
entity_id: media_player.pipup_living_room
data:
volume_level: 0.3For a specific video or playlist use pipup.launch_app with uri: https://www.youtube.com/watch?v=… instead of select_source. On a box whose volume belongs to the TV
set (HDMI-CEC, volume_fixed: true) volume_set is not offered; use volume_down/volume_up there.
At 21:00 on school nights whatever plays on the kids' TV is paused, with a card that says why. It only acts when the TV is actually on, and uses the info card blueprint for the card.
triggers:
- trigger: time
at: "21:00:00"
conditions:
- condition: time
weekday: [sun, mon, tue, wed, thu]
- condition: state
entity_id: media_player.pipup_kids_room
state: "on"
actions:
- action: media_player.media_pause
target:
entity_id: media_player.pipup_kids_room
- action: script.tv_info_card
data:
card_id: bedtime
title: Bedtime
message: Time to brush your teeth. Sleep well!
urgency: info
duration: 60
mode: singleThe media player only reports the screen, not whether something is playing, so media_pause on a
TV that is just showing its home screen does nothing — which is fine here. If a time trigger can be
missed (Home Assistant restarting at 21:00), add a homeassistant: start trigger with a time
condition between 21:00 and 21:15.
On TCL Google TV sets, PiPup does not stay up by itself. TCL runs a vendor guard
(com.tcl.guard) that both kills and freezes background apps, so sooner or later the TV
ends up in a state where the process is still listed but the HTTP server no longer answers: this
integration then reports the device as unreachable and every pipup.show fails, even though the TV
is online and ADB works. See the
app README
for the mechanism, the required app-ops and how to recognise a frozen process.
What matters here: the app survives only when it runs as a foreground service at
oom_score_adj 200, and that happens only if it was started from a foreground context. So the
recovery command must start the activity, not the service — am start-foreground-service lands
on adj 500 and gets frozen again within seconds.
This automation restarts PiPup whenever the integration reports the TV as unreachable while ADB is still answering. It needs the Android TV / ADB integration for the same TV.
alias: PiPup keep-alive (TCL)
description: >
Restarts PiPup on a TCL Google TV after the vendor guard killed or froze it.
Starts the activity (not the service): only a foreground start survives.
mode: single
triggers:
- trigger: state
entity_id: binary_sensor.pipup_living_room_reachable
to: "off"
for: "00:10:00"
- trigger: time_pattern
minutes: /15 # retry while PiPup stays down
- trigger: time
at: "05:00:00" # daily check
conditions:
# only when the TV itself is reachable — otherwise there is nothing to fix
- condition: state
entity_id: media_player.android_tv_living_room
state: ["idle", "standby", "off", "paused"]
- condition: or
conditions:
- condition: state
entity_id: binary_sensor.pipup_living_room_reachable
state: "off"
- condition: state
entity_id: binary_sensor.pipup_living_room_reachable
state: "unavailable"
actions:
- action: androidtv.adb_command
continue_on_error: true
target:
entity_id: media_player.android_tv_living_room
data:
command: >-
input keyevent KEYCODE_WAKEUP; sleep 2;
am start -n nl.rogro82.pipup/.MainActivity; sleep 3;
input keyevent KEYCODE_HOMENotes from running this in practice:
- Do not gate the command on
ps | grep pipup. A frozen process is still listed, so the guard short-circuits exactly when you need the restart. Starting unconditionally is safe. - The
statecondition keeps the automation from interrupting viewing: bringing the activity to the front briefly takes over the screen, so skip it while the TV isplaying. KEYCODE_WAKEUPis needed becauseam startdoes not reach the foreground while the TV is dreaming (screensaver). Note this wakes the screen — that is the price of the recovery.- After the app is reachable again, an integration entry that failed to set up earlier stays in
setup_retry; reload it (Settings → Devices & Services → PiPup → ⋮ → Reload) or the entities remain unavailable. Enabling/disabling the entry does not help — it is already enabled. - If you monitor port 7979 externally (e.g. Uptime Kuma), that sensor is a good extra trigger, because it detects a dead server slightly sooner than the coordinator does.
Pressing Install on the update entity self-updates silently on Android 12+. On Android < 12
(most Fire TV sticks, older Android TV boxes) the operating system forces an on-screen confirmation for
any app-initiated install — the REQUEST_INSTALL_PACKAGES permission does not change that, and no app
can grant itself the way around it (that needs a system/privileged app, an MDM device owner, or root).
So on those TVs the button wakes the screen and shows a confirmation popup with an Install button
that has to be accepted once with the remote (app ≥ 0.12.1). The update entity's requires_remote
attribute is true for exactly these devices.
Fully hands-off updates on Android < 12 are possible over ADB, because a shell-initiated
adb install -r has no on-screen confirmation on any Android version (only app-initiated installs do).
The integration can do that ADB install itself, in pure Python, so it also works on Home Assistant OS
(no adb binary needed). Per TV, open the integration's options (the gear icon) and expand
Advanced install:
| Field | Values | Default |
|---|---|---|
| Install method | App self-update (as before) / Auto (app on Android 12+, ADB below) / ADB | App self-update |
| ADB port | the TV's network-debugging port | 5555 |
| ADB key | PiPup's own key (.storage/pipup_adbkey, created on first use) / the Android TV integration's key (.storage/androidtv_adbkey) |
own |
Turn on network debugging (ADB) in the TV's developer options first. Choosing Auto or ADB tests the connection when you save. With a key the TV does not know yet, the TV shows Allow debugging from this computer? and the options ask you to tick Always allow and press OK. A TV that already trusts the Android TV integration needs nothing: pick that integration's key.
After that, Install on the update entity installs over ADB: the latest release's APK is downloaded
(and checked against the release's sha256), pushed to the TV, installed with pm install -r, and
the app-ops a reinstall resets are granted again (overlay, install, android:auto_start on TCL, the
accessibility service if it was on). The app's service is started in the background; only if it does not
come back (TCL's vendor guard) is the app opened briefly, and the TV goes back to sleep if it was
asleep. It never uninstalls the app (that would lose its device id): a build with another signature is
reported instead. The update entity's install_method attribute shows app or adb, and
requires_remote is false with ADB.
In the same section, Automatic updates installs each new release by itself inside a quiet window (default 03:00–05:00 local time; a window may cross midnight):
- one TV at a time, and only when the TV is reachable and no popup is on screen;
- one attempt per night: a failure is retried the next night, not every few minutes;
- on a TV that is on, the app is never opened. The install itself never interrupts what is playing (only PiPup restarts); if the app does not come back by itself, it starts the next time the TV is switched on;
- the app's own update is used only where it is silent (Android 12+); a TV on Android < 12 needs the install method Auto or ADB, and the options say so;
- a skipped version (Skip on the update entity) is not installed.
A success only writes a logbook entry such as PiPup app updated 0.29.2 → 0.30.0 (ADB). A failure
is logged and counted; a repair appears after three failed nights, or at once when something on the TV
needs fixing (debugging not allowed, a build with another signature, a key that cannot be read). It
disappears after the next successful update. The update entity shows auto_update,
last_auto_update (time, result, versions, method) and auto_update_failures.
Network debugging gives shell access to anyone on the LAN who is allowed by the TV, so it is opt-in and
off by default; the integration does nothing beyond what install.sh does.
The install.sh shipped with each app release does the
whole thing — download the latest APK, adb install -r, re-grant the two app-ops that every reinstall
resets, and start the service. Call it from Home Assistant with a shell_command:
# configuration.yaml — adb must be available where Home Assistant runs (see note below)
shell_command:
pipup_update_firetv: >-
/config/pipup/install.sh 192.168.1.10 192.168.1.11Then update those TVs on a schedule, or when the integration reports a newer version is available:
alias: PiPup silent update (Android < 12)
description: adb-installs the latest APK on TVs that cannot self-update silently
triggers:
- trigger: time
at: "04:30:00" # nightly; or trigger on the update entity below
conditions:
# only bother when an update is actually available and the TV needs the remote route
- condition: state
entity_id: update.pipup_living_room_app
attribute: requires_remote
state: true
- condition: template
value_template: >-
{{ is_state('update.pipup_living_room_app', 'on') }} {# on = update available #}
actions:
- action: shell_command.pipup_update_firetvNotes:
- adb has to be available where Home Assistant runs. Home Assistant OS does not ship it — use a
separate always-on host (a Pi/PC) with
android-tools-adbthat runs the script, or an ADB add-on. On Home Assistant Container/Core, installandroid-tools-adbin that environment. (The maintainer runs the script from a separate host, which is also how this fleet is kept updated.) - Enable ADB debugging on each TV once, and authorise the host's adb key (the same one the Android TV integration uses works well).
- On a TCL Google TV, add
--wake(its vendor guard freezes a background-started service); on other devices the install is silent and leaves the screen untouched. install.shre-appliesSYSTEM_ALERT_WINDOWandREQUEST_INSTALL_PACKAGESafter the install, so the overlay stays working — anadb install -rresets both.
PiPup runs an unauthenticated HTTP server on each TV, so any device on the network can show
popups (including arbitrary JavaScript via a web popup). Keep PiPup TVs on a trusted network
segment. See the app's Security section.
Button presses arrive over a local-only webhook. To stop a device on the LAN from forging a
pipup_button event (which could drive a security-sensitive automation such as a door lock), this
integration mints a single-use token for every button popup, passes it in the callback URL, and
rejects any callback whose token is missing, unknown or expired. The token is bound to the popup it
was issued for: the event's popup_id, label, device_id and device_name come from what HA
showed, and a button that was not on that popup is rejected. Traffic to the TV is plain HTTP, so
the token can still be sniffed within its short window on an untrusted network — another reason to
keep these devices on a segment you control.
- "unsupported_version" while adding — the TV runs the original PiPup; sideload the fork APK first.
- Connectivity sensor off / popup & screen "unknown" — the TV is off/asleep (FireTV sticks cut their network entirely in standby) or the PiPup service is not running. App ≥ 0.12.0 restarts the service by itself on boot, including a silent boot after a power cut; on older apps, open the app once after boot. Entities keep their last state on purpose; automate on the connectivity sensor's off→on edge.
- Buttons don't react to the remote — buttons require app ≥ 0.3.0; also note the popup takes input focus while buttons are visible (BACK gives control back to the TV app).
- Camera stream does not play — the TV must be able to reach your Home Assistant internal URL;
check Settings → System → Network → Home Assistant URL. Snapshot mode (
camera_mode: snapshot) works without any URL reachability. - Popup does not appear at all — grant the overlay permission once:
adb shell appops set nl.rogro82.pipup SYSTEM_ALERT_WINDOW allow. - TV is online and ADB works, but PiPup is unreachable (TCL sets) — the vendor guard froze the
process: it is still listed by
pswhile port 7979 no longer answers. Restart PiPup by starting its activity, and automate it — see Device notes: TCL Google TV. - Every
pipup.showfails with "No loaded PiPup devices in the action target" — either the target is not a PiPup entity/device, or the config entry is not loaded (it stays insetup_retrywhen the TV was unreachable at startup). Reload the entry; the target itself may be perfectly fine. Any entity of the device works as a target, not just the popup sensor.
Remove the device via Settings → Devices & Services; re-adding it restores everything (no state is stored on the TV).
MIT.
See CHANGELOG.md — every version also has a GitHub release with the full story in English and Dutch. Features that need a matching app version are marked there.