Android tv custom notifications

So far so good, I contacted the developer of the pipup fork (GitHub - steffjenl/PiPup at 0.1.7) and for now, feed display is not implemented. But it's on his to-do list. I will improve my driver when this feature becomes available. In the meantime, I've improved the driver to support another feature that pipup supports, namely displaying a web page. This is useful for displaying custom content or a link that sends to a web video stream such as mjpeg with Frigate, for example.

Here is the the complete instructions:

  • download latest .APK at: Release 0.1.7 ยท steffjenl/PiPup ยท GitHub

  • download adb (Android debug bridge)

  • use the following command: adb connect IP_address_of_AndroidTV

  • on AndroidTV device, autorize connection

  • use the following command: adb install C:\pathtoapk\pipup\app-release.apk

  • use the following command: adb shell appops set nl.rogro82.pipup SYSTEM_ALERT_WINDOW allow

Please note that you also need to setup a virtual device for your doorbell. See: Reolink Doorbell - receiving Visitor events in Hubitat - #13 by richard-brown

Latest Hubitat driver code:

/**
 *  PiPup Doorbell Auto v4.2 (Snapshot + Web Media + Fallback)
 *  Author: Patrick Gagne
 *  2025-11-14
 *
 *  New in v4.2:
 *    - Adds support for media.web (e.g. MJPEG streams or HTTP web pages)
 *    - Automatically chooses web OR image depending on settings
 */

metadata {
    definition (name: 'PiPup Doorbell Auto v4.2', namespace: 'pgagne', author: 'Patrick Gagne') {
        capability 'Notification'
        capability 'Switch'
        command 'notifyDoorbell'
    }

    preferences {
        input(name: "DebugLogging", type: "bool", title:"Enable Debug Logging", defaultValue: true)
        input(name: "TVIPAddress", type: "string", title:"Android TV / PiPup IP Address", defaultValue: "")

        // Image snapshot
        input(name: "SnapshotURL", type: "string", title:"Snapshot URL", defaultValue: "")
        input(name: "FallbackImageURL", type: "string", title:"Fallback Image URL", 
              defaultValue: "https://mir-s3-cdn-cf.behance.net/project_modules/max_1200/cfcc3137009463.5731d08bd66a1.png")

        // Web media
        input(name: "WebMediaURL", type: "string", title:"Web Media URL (optional)", 
              description:"If set, the driver sends media.web instead of an image", defaultValue: "")

        // Display settings
        input(name: "DisplayDuration", type: "number", title:"Duration (seconds)", defaultValue: 15)
        input(name: "Position", type: "number", title:"Position (0=TR,1=TL,2=BR,3=BL,4=Center)", defaultValue: 0)
        input(name: "DefaultTitle", type: "string", title:"Notification Title", defaultValue: "Doorbell")
        input(name: "TitleColor", type: "string", title:"Title Color", defaultValue: "#0066cc")
        input(name: "TitleSize", type: "number", title:"Title Size", defaultValue: 20)
        input(name: "MessageColor", type: "string", title:"Message Color", defaultValue: "#000000")
        input(name: "MessageSize", type: "number", title:"Message Size", defaultValue: 14)
        input(name: "BackgroundColor", type: "string", title:"Background Color", defaultValue: "#ffffff")
        input(name: "Message", type: "string", title:"Default Message Text", defaultValue: "Someone is at the door!")
    }
}

void installed() { debugLog("Installed") }
void updated() { debugLog("Preferences Updated") }
void initialize() { debugLog("Initialized") }

void deviceNotification(String text) {
    debugLog("deviceNotification received: ${text}")
    notifyDoorbell(text)
}

void on() {
    debugLog("Switch ON โ†’ sending notification")
    notifyDoorbell()
}

void off() { debugLog("Switch OFF โ†’ no action") }

void notifyDoorbell(String customMessage = null) {
    String messageText = customMessage ?: Message
    if (!TVIPAddress) {
        errorLog("TV IP address is missing!")
        return
    }

    if (WebMediaURL?.trim()) {
        sendWebMediaToPiPup(messageText)
    } else {
        sendSnapshotToPiPup(messageText)
    }
}

//
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  IMAGE SNAPSHOT MODE
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//

private void sendSnapshotToPiPup(String messageText) {
    def imageURL = SnapshotURL
    if (!imageURL) {
        errorLog("Snapshot URL missing โ†’ using fallback")
        imageURL = FallbackImageURL
    }

    // Validate snapshot
    try {
        httpGet([uri: imageURL, timeout: 5]) { resp ->
            if (resp.status != 200) {
                debugLog("Snapshot inaccessible (HTTP ${resp.status}) โ†’ fallback")
                imageURL = FallbackImageURL
            }
        }
    } catch (Exception e) {
        debugLog("Snapshot error: ${e} โ†’ fallback")
        imageURL = FallbackImageURL
    }

    def json = [
        duration: DisplayDuration,
        position: Position,
        title: DefaultTitle,
        titleColor: TitleColor,
        titleSize: TitleSize,
        message: messageText,
        messageColor: MessageColor,
        messageSize: MessageSize,
        backgroundColor: BackgroundColor,
        media: [
            image: [
                uri: imageURL,
                width: 480
            ]
        ]
    ]

    sendToPiPup(json, "snapshot")
}

//
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  WEB MEDIA MODE  (MJPEG, Dashboard, etc.)
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//

private void sendWebMediaToPiPup(String messageText) {
    String url = WebMediaURL

    def json = [
        duration: DisplayDuration,
        position: Position,
        title: DefaultTitle,
        titleColor: TitleColor,
        titleSize: TitleSize,
        message: messageText,
        messageColor: MessageColor,
        messageSize: MessageSize,
        backgroundColor: BackgroundColor,
        media: [
            web: [
                uri: url,
                width: 640,
                height: 480
            ]
        ]
    ]

    sendToPiPup(json, "web media")
}

//
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  SHARED PiPUP POST FUNCTION
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//

private void sendToPiPup(def json, String mode) {
    try {
        def postParams = [
            uri: "http://${TVIPAddress}:7979/notify",
            headers: ["Content-Type": "application/json"],
            body: groovy.json.JsonOutput.toJson(json)
        ]

        debugLog("JSON (${mode}) sent: ${groovy.json.JsonOutput.toJson(json)}")

        httpPost(postParams) { resp ->
            debugLog("PiPup response: ${resp.status}")
        }

    } catch (Exception e) {
        errorLog("Error sending notification: ${e}")
    }
}

// Logging
def debugLog(msg) { if (DebugLogging) log.debug msg }
def errorLog(msg) { log.error msg }

Device settings example:

2 Likes