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 // how long a device can be missing from the hub's mDNS cache before its row is flagged as stale // (~3 missed scans while background discovery is running; once it stops, the stamp is still refreshed by the // synchronous scanMdns() on every page render, so a device that is actually present is never flagged) @Field static final Long STALE_MS = 15 * 60 * 1000 // Discovery is a setup-time activity, so background scanning stops on its own instead of re-reading the hub's // mDNS cache forever. Opening the app, hitting Done, a hub reboot, or Refresh Devices re-arms it. @Field static final Integer DISCOVERY_WINDOW_MIN = 30 // ---------------------------------------------------------------------------- // lifecycle // ---------------------------------------------------------------------------- def installed() { updated() } def updated() { logDebug('updated') unsubscribe() unschedule() subscribe(location, 'systemStart', 'bootHandler') registerMdns(true) createParentDevice() 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() state.remove('discoveryUntilMs') // unschedule() above wiped the tick; force a fresh window armDiscovery() } 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) state.remove('discoveryUntilMs') // the hub's mDNS cache is empty after a reboot; scan again armDiscovery() } // 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 armDiscovery() 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 } } input name: 'rescan', type: 'button', title: 'Refresh Devices' paragraph "${discoveryStatus()}" int staleCount = candidates.findAll { dni, d -> isStale(d) }.size() if (staleCount > 0) { paragraph "${staleCount} device(s) above haven't answered mDNS for a while. Forget drops them from this list, and their Hubitat devices are removed when you click Done. A device that's only powered off comes back on its own — leave it alone unless it's really gone." input name: 'forgetStale', type: 'button', title: "Forget ${staleCount} device(s) not seen in mDNS" } } 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." paragraph 'Rule Machine strips < and > out of its text fields, so SSML typed in a rule never reaches the device. Write it with braces instead — Hello {break time="2s"/} World — and the driver converts it back.' } 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) armDiscovery() 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 case 'forgetStale': forgetStale() break } } // ---------------------------------------------------------------------------- // discovery // ---------------------------------------------------------------------------- // Extend the scanning window. A fresh window (or an expired one) also restarts the periodic tick and kicks // off a scan right away; inside an active window this only pushes the deadline out. private void armDiscovery() { boolean wasOff = ((state.discoveryUntilMs ?: 0L) as Long) < now() state.discoveryUntilMs = now() + (DISCOVERY_WINDOW_MIN * 60000L) if (!wasOff) return unschedule('discoveryTick') runEvery5Minutes('discoveryTick') runIn(6, 'scanMdns') // give the hub a few seconds to populate its mDNS cache before the first read logInfo("discovery: scanning for new devices for the next ${DISCOVERY_WINDOW_MIN} minutes") } def discoveryTick() { if (now() > ((state.discoveryUntilMs ?: 0L) as Long)) { unschedule('discoveryTick') logInfo('discovery: background scanning stopped; open the app or press Refresh Devices to scan again') return } scanMdns() } // Background scanning stops on its own, so show whether it is still running - otherwise "my new device never // showed up" has no visible explanation. private String discoveryStatus() { Long until = (state.discoveryUntilMs ?: 0L) as Long String scan = 'background scanning idle — press Refresh Devices to scan again' if (until > now()) scan = "scanning for new devices until ${clockTime(new Date(until))}" return "mDNS: ${state.lastScanFound ?: 0} record(s) on the last scan · ${scan}" } // 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 ?: [:] int found = 0 entries?.each { mac, v -> try { String ip = v.ip4Address if (ip) { found++ 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, lastSeenMs: now()] // drives isStale(); entries are only pruned on request } } catch (ex) { logWarn "scanMdns: skipping ${mac}: ${ex.message}" } } // "not seen in mDNS" only means something if this read actually returned devices. An empty result is // routine (listener not registered yet, cache not filled, firmware without the API, a hub that just // rebooted) and must NOT make every known device look stale - that wiped a user's whole device list. state.lastScanFound = found // entries saved before lastSeenMs existed start their clock at the first scan that worked, so they get // the full STALE_MS grace period instead of being flagged the moment the app is upgraded if (found > 0) disc.each { dni, d -> if (!d.lastSeenMs) d.lastSeenMs = now() } state.discovered = disc } catch (e) { logWarn "scanMdns: NetworkUtils mDNS lookup failed (${e.message})" state.lastScanFound = 0 // no evidence -> nothing is stale (see isStale) } } // 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, lastSeenMs: d.lastSeenMs, 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 } // Explicit prune (the 'Forget' button): drop every candidate currently flagged stale, plus its checkbox // setting. Deliberately NOT something Done does on its own - discovery is the only record of these devices, // and an empty or partial mDNS read would otherwise silently wipe the list. Their child devices go on the // next Done, when syncChildren finds them missing from the candidates. private void forgetStale() { Map cand = state.candidates ?: [:] List gone = cand.findAll { dni, d -> isStale(d) }.keySet().toList() if (gone.isEmpty()) { logInfo 'forgetStale: nothing flagged as stale'; return } Map disc = state.discovered ?: [:] gone.each { dni -> disc.remove(dni); app.removeSetting("sel_${cleanId(dni)}") } state.discovered = disc state.candidates = cand.findAll { !gone.contains(it.key) } logInfo "forgetStale: forgot ${gone.size()} device(s) not seen in mDNS: ${gone.join(', ')}" } 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' } // A candidate that has dropped out of the hub's mDNS cache. Discovery only ever merges into // state.discovered, so a device that was factory-reset or renamed (new deviceId -> new DNI), or a // speaker/display group edited or deleted in Google Home (groups get a fresh deviceId), leaves its old // entry behind forever - and since the checkbox defaults to checked, syncChildren keeps re-creating the // child. Flagging those is what this is for; removing them is the 'Forget' button, never automatic. // // Staleness needs POSITIVE evidence, both parts required: // - the entry has a real lastSeenMs (a missing stamp means "unknown", never "gone"), and // - the last scan actually returned devices (an empty read says nothing about any single device). // Without those two guards, a hub whose mDNS cache came back empty flagged every device as stale. private boolean isStale(Map d) { if (d?.source != 'mdns' || !d.lastSeenMs) return false if (((state.lastScanFound ?: 0) as Integer) < 1) return false return (now() - (d.lastSeenMs as Long)) > STALE_MS } // Jump straight to a created child's device page. It goes INSIDE the checkbox's title (which is already HTML), // floated right, so it sits at the right edge of the same row rather than on a line of its own - a table can't // do it, since the toggle's markup is generated by the hub and can't be wrapped in cells of ours. // target=_blank keeps the app page (and its unsaved checkbox state) put. The hub renders the title inside a //