Skip to content
mhoogenboschPublic

About

Home Assistant integration for PiPup (fork): show camera streams and notifications on Android TV for as long as you want

Topics

Resources

Stars

65 stars

Watchers

2 watching

Forks

Repository files navigation

PiPup — Home Assistant integration

Generated by aurora@aurora-smart-home (ha-integration-dev skill)

hacs_badge

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).

Features

  • 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_message target (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.show is 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_sleep and sleep_method say 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 -r resets 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. The fixable_on_tv attribute 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
  • Actions pipup.launch_app (an app by package, or a deep link) and pipup.send_key (media keys, home, back) — app ≥ 0.29.0

  • Camera wall in pipup.show with grid (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 replaced
    • popup_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_mode picks mjpeg (default — rendered in a WebView, audio-free), stream (HLS via ExoPlayer) or snapshot. With app ≥ 0.16.0 stream (and any video_url, incl. rtsp://) renders into a TextureView, so it shows over video the TV is already playing; on older apps it used a VideoView that could freeze or black out concurrent playback — update the app rather than avoiding stream. HLS runs a few seconds behind live. With go2rtc (stand-alone or in Frigate) use whep_url + poster_url instead (see below and Which camera route?): faster and steadier than any camera_mode
    • position is 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) and center_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_right and bottom_right stack along one side when several popups (each with its own popup_id) are shown at once.
    • muted: true by default (requires app ≥ 0.2.4) — audio in a popup can freeze video playback on some TVs; pass muted: false when 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); optional tts_language takes a BCP-47 tag such as nl-NL (device default when omitted)
    • padding → outer margin of the popup around its content in px (app ≥ 0.19.1); 0 = near-borderless
    • button_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 popup
    • animation → fade / slide_left / slide_right / slide_top / slide_bottom entrance animation, reversed on natural expiry (app ≥ 0.19.0); also a per-device default, and none in a call suppresses that default once. An update-in-place of the same popup never re-animates
    • sound → 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_volume 0–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 empty sound in a call suppresses that default once. Like tts this opens an audio path — some Fire TVs renegotiate HDMI audio briefly
    • dismiss_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. Set false to 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 the pipup_button event (with popup_id, button, label, device_id) via an automatically registered local-only webhook — no configuration needed; see the doorbell example below
    • show_progress → animated countdown bar for finite durations (app ≥ 0.3.0)
    • urgency → info/warning/critical colored 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 the urgency preset, so they combine: urgency: critical with border_width: 2 is a thin red border, and border_width: 0 drops the preset's frame. corner_radius also works without a border. Sizes are in pixels, and all three are available as per-device defaults too
    • whep_url → a WebRTC stream played straight from a WHEP endpoint, e.g. go2rtc http://<go2rtc>:1984/api/webrtc?src=<camera> (app ≥ 0.25.0). Same live stream as go2rtc's player page in web_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. muted defaults to true, and poster_url / transparent work as for web_url. An older app gets an error from the action instead of a failing popup
    • poster_url → a still image shown over a video_url/web_url/whep_url stream 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_entity popups get this automatically (mjpeg and stream modes): a signed snapshot of the same camera is sent as poster
    • icon → an image URL shown beside the title/message (app ≥ 0.13.0), notification-style, with icon_position (left default / right) and icon_width (pixels). Position and width also have per-device defaults; the icon URL itself is per call. Loaded like any other image, so a local http://homeassistant.local:8123/local/icons/… path works
  • Action pipup.dismiss: remove the popup with a given popup_id, else the popup shown without an id; all: true removes every popup. The Dismiss popup button removes every popup.

    Upgrading from ha-pipup ≤ 1.21 / app ≤ 0.23: a pipup.dismiss without popup_id used 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 with popup_id stays up, and nothing reports an error (the app answers 200 no popup without an id). Indefinite camera popups (duration: 0) then never go away. Pass the same popup_id as in pipup.show, or all: true to clear the TV.

  • Several popups at once (app >= 0.24.0): each popup_id is 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_front in pipup.show redraws a popup that is already up on top of the others. The Current popup sensor shows the one on top, with every id in its popups attribute; Popups on screen counts them.

  • Stacking (app >= 0.27.0): stack: vertical or stack: horizontal in pipup.show lines 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: none overrides 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_bleed runs the media edge to edge, and buttons_inline puts the buttons beside the text. button_color, button_focus_color, button_text_color, button_border_color and button_corner_radius restyle the buttons. A per-call media_height now 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_event event: 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_id and device_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.show fields opacity (0..1, the whole popup) and transparent (web_url only: a page with html, body { background: transparent } floats over the TV picture) (app >= 0.24.0).

  • Option App update source: github:owner/repo (default github:mhoogenbosch/PiPup), a folder URL with releases.json and the APKs (a LAN mirror for TVs without internet), or off. The app on the TV gets the same setting, so its own twice-daily check agrees; off also 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 stuck errored/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 holds SYSTEM_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.

Installation

HACS (Recommended)

  1. HACS → Integrations → Custom repositories
  2. Add https://github.com/mhoogenbosch/ha-pipup as Integration
  3. Install "PiPup" and restart Home Assistant

Manual

  1. Copy custom_components/pipup to your custom_components/
  2. Restart Home Assistant

Configuration

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 with address_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).

Screen on/off

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/.AdminReceiver

Devices 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.

TV control

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_pause

What 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.

Camera wall

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: Shed

All 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.

Blueprints

Two blueprints ship with the integration. Import them with the buttons, or paste the URL under Settings → Automations & scenes → Blueprints → Import blueprint.

Info card on the TV (script)

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 %}

Appliance done (automation)

Import blueprint

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.

Overlays

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).

  1. Configure the TV entry and fill Overlay pages, one line each: Name | URL.
  2. Add overlay on the integration page and give it a name. Its popup id is the name as a slug, fixed at creation.
  3. 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.

Which camera route?

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 once

Measured 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.

Examples

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.

House-wide announcement ("dinner is ready") with TTS

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: false

Wire 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.

Info popup with a templated message (energy summary)

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) }}%

Doorbell popup with an "open the door" button

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"

Calendar reminder 15 minutes ahead

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 }}"

Show camera while there is motion

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 removed

When 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.

Doorbell: pause the TV and show the camera wall (app ≥ 0.29.0)

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 nothing

The 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.

Dashboard button: YouTube on the living-room TV at a fixed volume (app ≥ 0.29.0)

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.3

For 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.

Bedtime: pause the kids' TV with a card (app ≥ 0.29.0)

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: single

The 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.

Device notes: TCL Google TV needs a keep-alive automation

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_HOME

Notes 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 state condition keeps the automation from interrupting viewing: bringing the activity to the front briefly takes over the screen, so skip it while the TV is playing.
  • KEYCODE_WAKEUP is needed because am start does 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.

Known limitations

The update button is silent only on Android 12+

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).

Built in: Advanced install (1.29.0)

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.

Automatic updates (1.30.0)

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.

Alternative: install.sh

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.11

Then 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_firetv

Notes:

  • 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-adb that runs the script, or an ADB add-on. On Home Assistant Container/Core, install android-tools-adb in 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.sh re-applies SYSTEM_ALERT_WINDOW and REQUEST_INSTALL_PACKAGES after the install, so the overlay stays working — an adb install -r resets both.

Security

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.

Troubleshooting

  • "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 ps while port 7979 no longer answers. Restart PiPup by starting its activity, and automate it — see Device notes: TCL Google TV.
  • Every pipup.show fails 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 in setup_retry when 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.

Recovery

Remove the device via Settings → Devices & Services; re-adding it restores everything (no state is stored on the TV).

License

MIT.

Changelog

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.

About

Home Assistant integration for PiPup (fork): show camera streams and notifications on Android TV for as long as you want

Topics

Resources

Stars

65 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages