import groovy.transform.Field /** * ------------------------------------------------------------------------------------------------------------------------------ * ** Google Chromecast+ (App) ** * * Discovers Google Cast / Chromecast devices on the LAN and lets you pick which ones to control from Hubitat. * * Discovery uses Hubitat's apps-only mDNS listener API (registerMDNSListener / getMDNSEntries, firmware * 2.4.1.151+) for the "_googlecast._tcp" service, with manual IP entry as a fallback. Selected devices are * created as child devices under a single top-level "Google Chromecast+" parent device (the same driver * runs in parent mode there and child mode on each Chromecast). * * Note: the hub fills its mDNS cache in the background AFTER the listener is registered, so discovery can * take 15-60s on first install. Results are accumulated into state so devices stay listed once seen. * ------------------------------------------------------------------------------------------------------------------------------ **/ definition( name: 'Google Chromecast+', namespace: 'jpage4500', author: 'Joe Page', description: 'Discover and control Google Cast / Chromecast devices (status, media, TTS)', importUrl: 'https://raw.githubusercontent.com/jpage4500/hubitat-drivers/master/google-chromecast-plus/google-chromecast-plus-app.groovy', category: 'Convenience', menu: "Integrations", oauth: false, iconUrl: '', iconX2Url: '', iconX3Url: '' ) preferences { page(name: 'mainPage') } @Field static final String DRIVER = 'Google Chromecast+' @Field static final String PARENT_DRIVER = 'Google Chromecast+ Parent' @Field static final String MDNS_SERVICE = '_googlecast._tcp' @Field static final Integer DEFAULT_PORT = 8009 // ---------------------------------------------------------------------------- // lifecycle // ---------------------------------------------------------------------------- def installed() { updated() } def updated() { logDebug('updated') unsubscribe() unschedule() subscribe(location, 'systemStart', 'bootHandler') registerMdns(true) createParentDevice() // Remove Stale Devices add on function removeSelectedStaleDevices() // End Remove Stale Devices Add on function syncChildren() def parent = getParentDevice() if (parent) { parent.setRefreshInterval((settings.refreshInterval ?: 60) as Integer) parent.setDebug(settings.debugOutput == true) // single toggle -> broadcast to parent + all children } // debug logging auto-disables 24h after being enabled so verbose logs are never left on if (settings.debugOutput == true) { runIn(86400, 'debugOff') state.debugDisableMs = now() + 86400000 // when auto-off fires; surfaced on the main page } else { state.remove('debugDisableMs') } schedulePolling() // give the hub a few seconds to populate its mDNS cache, then keep it fresh runIn(6, 'scanMdns') runEvery5Minutes('scanMdns') } def uninstalled() { def parent = getParentDevice() if (parent) parent.removeAllChildren() getChildDevices().each { deleteChildDevice(it.deviceNetworkId) } } // mDNS listener is cleared on reboot -> re-register on system start def bootHandler(evt) { registerMdns(true); runIn(10, 'scanMdns') } // scheduled by updated() 24h after debug is enabled: clear the app toggle + broadcast off to parent/children def debugOff() { logInfo('auto-disabling debug logging (24h elapsed)') app.updateSetting('debugOutput', [value: 'false', type: 'bool']) getParentDevice()?.setDebug(false) state.remove('debugDisableMs') } // throttled so rapid page re-renders don't spam the hub; force=true always re-registers private void registerMdns(boolean force = false) { if (!force && state.lastRegisterMs && (now() - (state.lastRegisterMs as Long)) < 60000) return try { registerMDNSListener(MDNS_SERVICE) state.lastRegisterMs = now() state.mdnsAvailable = true logDebug("registered mDNS listener for ${MDNS_SERVICE}") } catch (e) { logWarn "registerMDNSListener unavailable (needs firmware 2.4.1.151+): ${e.message}" state.mdnsAvailable = false } } // central polling: ONE timer in the app refreshes every child (each child does a short on-demand // connect -> GET_STATUS -> disconnect). Keeps all cadence + a single summary log in one place. private void schedulePolling() { unschedule('pollDevices') Integer sec = (settings.refreshInterval ?: 60) as Integer if (sec < 60) schedule("0/${sec} * * * * ?", 'pollDevices') else schedule("0 0/${(sec / 60) as Integer} * * * ?", 'pollDevices') } def pollDevices() { def parent = getParentDevice() def kids = (parent?.getChildDevices()) ?: [] logDebug("pollDevices: refreshing ${kids.size()} device(s)") kids.each { try { it.refresh() } catch (e) { logWarn "pollDevices: ${it} - ${e.message}" } } } // ---------------------------------------------------------------------------- // UI // ---------------------------------------------------------------------------- def mainPage() { registerMdns() // ensure the listener is active as soon as the page is opened createParentDevice() Map candidates = discoverDevices() dynamicPage(name: 'mainPage', title: '', install: true, uninstall: true) { section(header('Google Chromecast+')) { paragraph 'Discover Chromecast / Google Cast devices and create a Hubitat device for each one you want to monitor and control.' } section(header('Devices')) { if (candidates.isEmpty()) { paragraph "No devices found yet. Discovery reads the hub's mDNS cache — click Rescan and reopen, or add one by IP below." } else { Map childByDni = (getParentDevice()?.getChildDevices() ?: []).collectEntries { [(it.deviceNetworkId): it] } paragraph "Checked devices are created in Hubitat. Uncheck to remove. Hit DONE to apply changes" candidates.sort { it.value.name }.each { dni, d -> input name: "sel_${cleanId(dni)}", type: 'bool', title: deviceRow(d, childByDni[dni]), defaultValue: true, submitOnChange: true } } // ================================================================ // BEGIN STALE DEVICE REVIEW // ================================================================ Map currentMdns = getCurrentMdnsDevices() Map staleDevices = getPotentiallyStaleDevices(currentMdns) if (!staleDevices.isEmpty()) { paragraph "Potentially Stale Devices
" + "These existing Chromecast+ devices were not found " + "in the current mDNS scan. They are unchecked by default. " + "Check the ones you want to remove, then click DONE." staleDevices.sort { it.value.name }.each { dni, d -> input name: "remove_${cleanId(dni)}", type: 'bool', title: staleDeviceRow(d), defaultValue: false, submitOnChange: true } } else if (!currentMdns.isEmpty()) { paragraph "No potentially stale Chromecast+ devices found." } // END STALE DEVICE REVIEW // ================================================================ input name: 'rescan', type: 'button', title: 'Refresh Devices' } section(hideable: true, hidden: true, 'Add a device by IP') { paragraph 'For a device not auto-discovered (or on another subnet). It is added and connects immediately.' input name: 'manualIp', type: 'text', title: 'IP address', required: false, submitOnChange: true input name: 'manualName', type: 'text', title: 'Name (optional)', required: false, submitOnChange: true input name: 'addManual', type: 'button', title: 'Add device' if (state.manual) input name: 'clearManual', type: 'button', title: 'Clear manual entries' } section(header('Settings')) { input name: 'refreshInterval', type: 'number', title: 'Status refresh interval (seconds)', defaultValue: 60, range: '10..3600', submitOnChange: true input name: 'debugOutput', type: 'bool', title: 'Enable debug logging (auto-off after 24h)', defaultValue: false, submitOnChange: true paragraph "Announcement volume and lead-in delay are set per device — open a Chromecast device to change them." } section { if (settings.debugOutput == true && state.debugDisableMs) { paragraph "Debug logging will be disabled at ${clockTime(new Date(state.debugDisableMs as Long))}" } paragraph "Selected/added devices become child devices under the '${DRIVER}' parent device. Click Done to apply." } } } def appButtonHandler(btn) { switch (btn) { case 'rescan': registerMdns(true) scanMdns() break case 'addManual': if (!isEmpty(settings.manualIp)) { String ip = settings.manualIp.trim() String nm = settings.manualName?.trim() def list = state.manual ?: [] if (!list.any { it.ip == ip }) { list << [ip: ip, name: nm, port: DEFAULT_PORT] state.manual = list } // create the child right away (also exercises the TLS connect) createParentDevice() def p = getParentDevice() if (p) p.createChild(manualDni(ip), nm, ip, "${DEFAULT_PORT}", null) logDebug("added manual device ${ip}") app.updateSetting('manualIp', [value: '', type: 'text']) app.updateSetting('manualName', [value: '', type: 'text']) } break case 'clearManual': state.manual = [] break } } // ---------------------------------------------------------------------------- // discovery // ---------------------------------------------------------------------------- // Read the hub's current mDNS cache and merge into state.discovered (accumulates across renders/reboots-of-page). def scanMdns() { try { // Reads the hub's already-populated mDNS cache synchronously (the hub keeps _googlecast._tcp warm). // Returns a Map; bean properties: ip4Address, port, friendlyName, // deviceId (uuid), model, macAddress, txtProperties. NOTE: it's a bean, not a Map -> accessing a // property that doesn't exist throws, so only touch the real property names below. Map entries = hubitat.helper.NetworkUtils.getRawMDNSEndpointsByMACForServiceType('_googlecast._tcp.local.') logDebug("scanMdns: ${entries?.size() ?: 0} entries") def disc = state.discovered ?: [:] entries?.each { mac, v -> try { String ip = v.ip4Address if (ip) { String uuid = v.deviceId String name = v.friendlyName ?: v.name ?: ip Integer port = (v.port ?: DEFAULT_PORT) as Integer String model = v.model // the bean also carries the mDNS TXT records the code otherwise ignores; parse them for // a device-type hint (audio/video/group) and the current receiver-status text (rs). Map txt = parseTxt(v.txtProperties) String deviceType = castDeviceType(txt.ca) String dni = "GoogleChromecastPlus-${cleanId((uuid ?: mac ?: ip).toString())}" disc[dni] = [ip: ip, port: port, name: name, uuid: uuid, mac: mac?.toString(), model: model, deviceType: deviceType, statusText: txt.rs, castVersion: txt.ve] } } catch (ex) { logWarn "scanMdns: skipping ${mac}: ${ex.message}" } } state.discovered = disc } catch (e) { logWarn "scanMdns: NetworkUtils mDNS lookup failed (${e.message})" } } // Build a dni -> [ip, port, name, uuid, source] map from accumulated mDNS results + manual entries. Map discoverDevices() { scanMdns() Map candidates = [:] (state.discovered ?: [:]).each { dni, d -> candidates[dni] = [ip: d.ip, port: d.port, name: d.name, uuid: d.uuid, model: d.model, deviceType: d.deviceType, statusText: d.statusText, source: 'mdns'] } (state.manual ?: []).each { m -> candidates[manualDni(m.ip)] = [ip: m.ip, port: (m.port ?: DEFAULT_PORT), name: (m.name ?: m.ip), uuid: null, source: 'manual'] } state.candidates = candidates return candidates } // ================================================================ // BEGIN STALE DEVICE REVIEW // ================================================================ private Map getCurrentMdnsDevices() { Map current = [:] try { Map entries = hubitat.helper.NetworkUtils.getRawMDNSEndpointsByMACForServiceType('_googlecast._tcp.local.') entries?.each { mac, v -> try { String ip = v.ip4Address if (ip) { String uuid = v.deviceId String dni = "GoogleChromecastPlus-${cleanId((uuid ?: mac ?: ip).toString())}" current[dni] = [ ip: ip, uuid: uuid, name: v.friendlyName ?: v.name ?: ip, mac: mac?.toString() ] } } catch (ex) { logWarn "getCurrentMdnsDevices: skipping ${mac}: ${ex.message}" } } logDebug "getCurrentMdnsDevices: ${current.size()} current device(s)" } catch (e) { logWarn "getCurrentMdnsDevices: NetworkUtils mDNS lookup failed (${e.message})" } return current } private Map getPotentiallyStaleDevices(Map currentMdns) { Map stale = [:] Map discovered = state.discovered ?: [:] discovered.each { dni, d -> // If the device is still present in the current mDNS scan, // it is not stale. if (currentMdns.containsKey(dni)) { return } // This entry exists in state.discovered but was NOT found // in the current mDNS scan. stale[dni] = [ name: d.name ?: 'Chromecast', ip: d.ip ?: 'unknown', uuid: d.uuid, mac: d.mac, model: d.model, deviceType: d.deviceType, statusText: d.statusText ] } logDebug "getPotentiallyStaleDevices: ${stale.size()} potentially stale entry(s)" return stale } private String staleDeviceRow(Map d) { String s = "${d.name ?: 'Chromecast'} — ${d.ip ?: 'unknown'}" if (d.connectionStatus) { s += " · ${d.connectionStatus}" } if (d.playbackStatus && !(d.playbackStatus in ['IDLE', 'OFFLINE', 'UNKNOWN'])) { s += " · ${d.playbackStatus}" } return s } // END STALE DEVICE REVIEW // ================================================================ private String cleanId(String s) { return (s ?: '').replaceAll('[^A-Za-z0-9]', '') } private String manualDni(String ip) { return "GoogleChromecastPlus-${cleanId(ip)}" } // The mDNS bean's txtProperties shape isn't documented for Hubitat's NetworkUtils, so handle whatever it is: // a Map, a List/array of "key=value" strings, or a single space/newline-joined string. // Logs the raw form once at debug so the real shape can be confirmed on a live hub. Returns [:] on anything odd. private Map parseTxt(raw) { if (raw == null) return [:] // getClass() is blocked in the Hubitat sandbox, so log the raw value + instanceof flags instead (enough to ID the shape) if (!state.loggedTxtShape) { logDebug("parseTxt: raw txtProperties=${raw} (map=${raw instanceof Map}, list=${raw instanceof List})"); state.loggedTxtShape = true } Map out = [:] try { if (raw instanceof Map) { raw.each { k, val -> if (k != null) out[k.toString()] = val?.toString() } } else { def items = (raw instanceof List || raw instanceof Object[]) ? raw.toList() : raw.toString().split(/[\r\n ]+/).toList() items.each { entry -> String e = entry?.toString() int i = e ? e.indexOf('=') : -1 if (i > 0) out[e.substring(0, i)] = e.substring(i + 1) } } } catch (ex) { logWarn "parseTxt: could not parse txtProperties (${ex.message})" return [:] } return out } // Map the Cast 'ca' capabilities bitmask to a coarse device type. Best-effort (bits are community-documented, // not official): bit0=video-out, bit2=audio-out, bit5=multizone group. Returns 'unknown' when absent/unparseable. private String castDeviceType(caValue) { if (caValue == null) return 'unknown' Integer ca try { ca = caValue.toString().trim() as Integer } catch (ignored) { return 'unknown' } if (ca & 0x20) return 'group' // multizone -> speaker/display group if (ca & 0x01) return 'video' // has video output -> TV / dongle / display if (ca & 0x04) return 'audio' // audio out only -> speaker return 'unknown' } // one selectable row: name/ip/model + a live-status line pulled from the created child (if any) private String deviceRow(Map d, child) { String s = "${d.name} — ${d.ip}" if (d.model) s += " · ${d.model}" if (d.deviceType && d.deviceType != 'unknown') s += " · ${d.deviceType}" if (d.source == 'manual') s += " · manual" // before the device is added we have no child to query; fall back to the mDNS receiver-status text (rs) String extra = d.statusText ?: 'not added yet' if (child) { String cs = child.currentValue('connectionStatus') ?: 'idle' String ps = child.currentValue('playbackStatus') if (ps && !(ps in ['IDLE', 'OFFLINE', 'UNKNOWN'])) { extra = ps.toLowerCase() String t = child.currentValue('mediaTitle') String a = child.currentValue('currentApp') if (t) extra += " · ${t}" else if (a && a != 'none') extra += " · ${a}" } else { extra = cs // "since" only reads well for the sticky states, so append it just for online/offline. the driver // keeps connectionStatus on those two outcomes (no transient "connecting"), so the timestamp of the // current event is a stable "online since 12:42 AM" that doesn't reset on every poll/reconnect if (cs in ['online', 'offline']) { Date since = child.currentState('connectionStatus')?.date if (since) extra += " since ${clockTime(since)}" } } } return s + "
${extra}" } // ---------------------------------------------------------------------------- // child / parent devices // ---------------------------------------------------------------------------- private String parentDni() { "GoogleChromecastPlus-parent-${app.id}" } private void createParentDevice() { def parent = getChildDevice(parentDni()) if (!parent) { try { parent = addChildDevice('jpage4500', PARENT_DRIVER, parentDni(), [label: 'Google Chromecast+', isComponent: true, name: PARENT_DRIVER]) parent.initialize() logInfo 'createParentDevice: created parent device' } catch (e) { logError "createParentDevice: failed - is the '${PARENT_DRIVER}' driver installed? ${e.message}" } } } private getParentDevice() { getChildDevice(parentDni()) } // reconcile wanted devices (a checkbox per candidate, checked by default) against existing child devices private void syncChildren() { def parent = getParentDevice() if (!parent) { logError 'syncChildren: no parent device'; return } Set wanted = [] as Set (state.candidates ?: [:]).each { dni, d -> if (isSelected(dni)) { parent.createChild(dni, d.name, d.ip, "${d.port}", d.uuid, d.deviceType); wanted << dni } } parent.getChildDevices().each { child -> if (!wanted.contains(child.deviceNetworkId)) parent.deleteChild(child.deviceNetworkId) } } private boolean isSelected(String dni) { return settings["sel_${cleanId(dni)}"] != false } // checked by default // ================================================================ // BEGIN STALE DEVICE REVIEW - REMOVAL // ================================================================ private void removeSelectedStaleDevices() { Map discovered = state.discovered ?: [:] if (discovered.isEmpty()) return List removed = [] discovered.keySet().toList().each { dni -> String settingName = "remove_${cleanId(dni)}" if (settings[settingName] == true) { removed << dni discovered.remove(dni) app.removeSetting(settingName) } } if (!removed.isEmpty()) { state.discovered = discovered // Also remove them from the cached candidate list Map candidates = state.candidates ?: [:] removed.each { dni -> candidates.remove(dni) } state.candidates = candidates logInfo "removeSelectedStaleDevices: removed ${removed.size()} stale discovery entry(s): ${removed.join(', ')}" } } // END STALE DEVICE REVIEW - REMOVAL // ================================================================ // ---------------------------------------------------------------------------- // util // ---------------------------------------------------------------------------- private String header(String text) { return "
${text}
" } private boolean isEmpty(def v) { return v == null || (v instanceof String && v.trim().isEmpty()) } // short local clock time, e.g. "12:42 AM" (hub-local via the JVM default timezone, as in the other drivers) private String clockTime(Date d) { d ? d.format('M/d h:mm a') : '' } // same "GC+ [App] " prefix as the driver, so the Logs filter "GC+" shows app + all devices together private void logDebug(msg) { if (settings.debugOutput) logAppAt('debug', msg) } private void logInfo(msg) { logAppAt('info', msg) } private void logWarn(msg) { logAppAt('warn', msg) } private void logError(msg) { logAppAt('error', msg) } private void logAppAt(String level, msg) { log."${level}"("GC+ [App] ${msg}") }