/* Sofabaton Integration - Parent App Copyright 2026 Jason Thomas. All Rights Reserved Credits: the X1/X1S local receive path (Sofabaton Remote driver) is a fork of Derek Osborn's (dJOS1475) Hubitat community driver, built on push-command building blocks originated by Mike Maxwell (mike.maxwell). Notes: -Setup only. Runtime (HTTP listener, MQTT, webhooks, state sync, buttons) lives in the Bridge, Remote, and Activity drivers. -Device tree: Bridge -> one Remote per hub -> one Activity per activity. X1S keyed by IP (ipToHex DNI), X2 keyed by bare uppercase MAC. -X2 needs Hubitat 2.5.2.126+. Activities are created automatically from the hub's list; Add an Activity is X1S only. "Find My X2" reads MACs the Bridge has seen, so users never need MQTT Explorer. -Activity labels default to "-" so they group in device pickers. The main page calls applyActivityLabels() on each hub (a no-op after the first run); renaming a hub relabels its default-named activities. Pills show the short name. -X2 remote buttons: the Remote learns the Hubitat Control device from the first press. Edit Hub can set it manually. Button Actions maps each button to switches (Toggle/On/Off); the app subscribes to the hub's pushed events. Group toggle: any on -> all off, else all on. Button events still fire on the hub device for Rule Machine or Button Controller. -Add/Edit pages save ONLY on an explicit Save click (state.*SaveRequested). Hubitat's Done is hidden there (it left without saving). Cancel and Save sit together at the bottom as links to mainPage (so a browser reload stays on the main page), with a one-time token so a reload can't re-submit. Save is gray until required fields are filled. -Edit/Remove use buttons + state, not hrefs with params (Hubitat drops params across same-page hrefs). -No required:true on Add page inputs (browser validation blocked Cancel). No refreshInterval (bounced to mainPage); button presses re-render instead. -uninstalled() must delete the Bridge, or everything under it is orphaned. -Model, MAC, and X1S IP set the DNI, so they're not editable. Remove and re-add. -Log level is the only logging control. It cascades App -> Bridge -> Remotes -> Activities. Every driver sends its log lines back up to childLog(), so all Sofabaton logging appears under this app, labeled [Bridge], [hub], or [activity]. View logs shows everything. -Refresh and Add Hub pause 1.5s so the page redraws after the hub's list reply (~0.3s). -UI follows HubitatAppUiTemplate (Reolink, UI design by gopher.ny). Dark mode uses the hub's theme classes (hub-text-*, app-ui-button*, app-ui-links/navigation, surface-*, p-message/p-tag) and var() tokens with fallbacks; legacyLightThemeCss() covers pre-2.5.3 firmware (pattern by vurvantsev, Reolink 1.6.13). */ import groovy.transform.Field @Field static final String APP_NAME = "Sofabaton Integration" @Field static final String APP_VERSION = "1.1.4" @Field static final String COMMUNITY_URL = "https://community.hubitat.com" // TODO: release thread @Field static final String COFFEE_URL = "https://www.paypal.com/paypalme/jdthomas24?locale.x=en_US&country.x=US" @Field static final String DEFAULT_TIP_TOPIC = "start" @Field static final List LOG_LEVELS = ["Errors Only", "Normal", "Full"] // Add/Edit pages: Hubitat's Done would leave without saving, so only Cancel and Save are shown. @Field static final String HIDE_DONE_CSS = """""" @Field static final String HUBITAT_PILL = "HUBITAT" @Field static final String SOFABATON_PILL = "SOFABATON APP" definition( name: "Sofabaton Integration", namespace: "jdthomas24", author: "Jason Thomas", description: "Manage one or more Sofabaton X Series hubs and their activities", category: "Convenience", menu: "Integrations", singleInstance: true, iconUrl: "", iconX2Url: "", importUrl: "https://raw.githubusercontent.com/jdthomas24/Hubitat-Apps-Drivers/main/SofaBaton%20Integration/ParentApp.groovy" ) preferences { page(name: "mainPage") page(name: "addHubPage") page(name: "addActivityPage") page(name: "buttonActionsPage") page(name: "tipsPage") } def installed() { initialize() } def updated() { initialize() } // Deleting the Bridge triggers its own uninstalled(), which tears down MQTT and children. def uninstalled() { def bridge = getChildDevice(bridgeDni()) if (bridge) { try { deleteChildDevice(bridge.deviceNetworkId) } catch (e) { logErr "failed to remove Bridge device on uninstall: ${e.message}" } } } def initialize() { getBridge() pushLogLevel() subscribeButtons() } // Sends the app's level to every Sofabaton device via the Bridge. private void pushLogLevel() { String level = currentLogLevel() getBridge()?.setLogLevel(level) state.pushedLogLevel = level } private String bridgeDni() { return "sofabaton-bridge-${app.id}" } // Created lazily: a first-run user can open Add a Hub before ever tapping Done. def getBridge() { def bridge = getChildDevice(bridgeDni()) if (!bridge) { logDbg "Bridge not found, creating it" bridge = addChildDevice("jdthomas24", "Sofabaton Integration Bridge", bridgeDni(), [label: "Sofabaton Integration Bridge"]) } return bridge } // ============================================================================ // Logging // ============================================================================ private String currentLogLevel() { return LOG_LEVELS.contains(logLevel) ? logLevel : "Errors Only" } private void logErr(String msg) { log.error "${APP_NAME}: ${msg}" } private void logWarn(String msg) { log.warn "${APP_NAME}: ${msg}" } private void logInfo(String msg) { if (currentLogLevel() != "Errors Only") log.info "${APP_NAME}: ${msg}" } private void logDbg(String msg) { if (currentLogLevel() == "Full") log.debug "${APP_NAME}: ${msg}" } // Called by the Bridge for every driver log line. Drivers already filter by level. void childLog(String level, String source, String msg) { String line = "${APP_NAME} [${source}]: ${msg}" switch (level) { case "error": log.error line; break case "warn": log.warn line; break case "info": log.info line; break default: log.debug line } } // Full logging reverts to Errors Only after 30 minutes. private void armLogRevert() { if (currentLogLevel() == "Full") { if (!state.fullLogArmed) { runIn(1800, "revertLogLevel") state.fullLogArmed = true } } else { state.remove("fullLogArmed") } } void revertLogLevel() { state.remove("fullLogArmed") if (currentLogLevel() == "Full") { app.updateSetting("logLevel", [value: "Errors Only", type: "enum"]) pushLogLevel() log.warn "${APP_NAME}: Full logging turned off after 30 minutes" } } // ============================================================================ // Main page // ============================================================================ def mainPage(params = null) { // Save/Cancel on Add/Edit pages are links to mainPage, so the browser URL ends up here // and a reload stays on the main page. The token stops a reload from re-submitting. if (params?.formAction && params?.token && params.token == state.formToken) { state.remove("formToken") switch (params.formAction) { case "saveHub": state.hubSaveRequested = true; return addHubPage() case "cancelHub": state.hubPageCancelled = true; return addHubPage() case "saveActivity": state.activitySaveRequested = true; return addActivityPage() case "cancelActivity": state.activityPageCancelled = true; return addActivityPage() } } if (state.editingHubDni) return addHubPage() if (state.editingActivityKey) return addActivityPage() // Clear half-entered fields from a cancelled attempt. clearHubSettings() clearActivitySettings() ["activitySaveRequested", "discoveryStarted", "findError"].each { state.remove(it) } armLogRevert() if (state.pushedLogLevel != currentLogLevel()) pushLogLevel() def bridge = getBridge() def hubs = bridge?.getChildDevices() ?: [] hubs.each { h -> try { h.applyActivityLabels() } catch (e) { } } // one-time label migration per hub subscribeButtons() // picks up changes made on Button Actions def x2Hubs = hubs.findAll { it.currentValue("hubModel") == "X2" } def x1sHubs = hubs.findAll { it.currentValue("hubModel") != "X2" } List allActivities = hubs.collectMany { it.getChildDevices() ?: [] } int removedCount = allActivities.count { it.currentValue("onHub") == "false" } as int int activityCount = allActivities.size() - removedCount Map banner = bannerState(bridge, hubs, x2Hubs, activityCount, removedCount) dynamicPage(name: "mainPage", install: true, uninstall: true) { section { paragraph rawHtml: true, statusBannerHtml(banner.ok, banner.title, banner.summary) } section(sectionClass: "app-ui-links app-main-content") { paragraph rawHtml: true, columnHeader("Hubs") if (!hubs) { paragraph "No hubs added yet. Tap Add a Hub below." } hubs.each { hub -> String dni = hub.deviceNetworkId String hubLabel = hub.getLabel() def activities = hub.getChildDevices() ?: [] paragraph rawHtml: true, hubCardHtml(hub, activities) // Hubitat can't put inputs inside HTML, so these sit tight under the card (see sb-hub-btn CSS). if (hub.currentValue("hubModel") == "X2") { input name: "editHub_${dni}", type: "button", title: "Edit Hub", width: 3, inputClass: "app-ui-button" input name: "syncHub_${dni}", type: "button", title: "Refresh Activities", width: 3, inputClass: "app-ui-button" href name: "btnActs_${dni}", title: "Button Actions", description: "", page: "buttonActionsPage", params: [hub: dni], width: 3 input name: "removeHub_${dni}", type: "button", title: "Remove Hub", width: 3, inputClass: "app-ui-button" activities.findAll { it.currentValue("onHub") == "false" }.each { act -> input name: "removeAct_${dni}_${act.deviceNetworkId}", type: "button", title: "Remove ${shortName(act, hubLabel)}", width: 3, inputClass: "app-ui-button app-ui-button-danger" } } else { input name: "editHub_${dni}", type: "button", title: "Edit Hub", width: 3, inputClass: "app-ui-button" input name: "removeHub_${dni}", type: "button", title: "Remove Hub", width: 3, inputClass: "app-ui-button" activities.each { act -> input name: "editAct_${dni}_${act.deviceNetworkId}", type: "button", title: "Edit ${shortName(act, hubLabel)}", width: 3, inputClass: "app-ui-button" input name: "removeAct_${dni}_${act.deviceNetworkId}", type: "button", title: "Remove ${shortName(act, hubLabel)}", width: 3, inputClass: "app-ui-button" } } paragraph rawHtml: true, "
" } href name: "toAddHub", title: "Add a Hub", description: "X1S or X2", page: "addHubPage", width: 12, style: "margin:8px;" if (x1sHubs) { href name: "toAddActivity", title: "Add an Activity", description: "X1S only. X2 activities are added automatically.", page: "addActivityPage", width: 12, style: "margin:8px;" } if (hubs) { paragraph "Tap Done when finished. That's what finalizes the install." } } section(sectionClass: "app-ui-links app-main-settings") { paragraph rawHtml: true, columnHeader("Quick Settings") if (bridge) { paragraph rawHtml: true, supportLinkHtml("/device/edit/${bridge.id}", "pi pi-sitemap", "Bridge Device", "Connection status and MQTT diagnostics") } if (x2Hubs) { paragraph rawHtml: true, mqttStatusHtml(bridge, x2Hubs) } input "logLevel", "enum", title: "Log level", options: LOG_LEVELS, defaultValue: "Errors Only", submitOnChange: true, width: 12, style: "margin-left: 0.5em; margin-right: 0.5em; margin-top: 0; padding-right:0;" paragraph rawHtml: true, loggingDetailsPopupHtml() } helpAndSupportSection() versionFooterSection() } } // Activity name without the "-" prefix, for pills and buttons. private String shortName(act, String hubLabel) { String label = act.getLabel() ?: act.displayName String prefix = hubLabel ? "${hubLabel}-".toString() : null return (prefix && label?.startsWith(prefix) && label.length() > prefix.length()) ? label.substring(prefix.length()) : label } // Green only when everything is confirmed. X2 needs a real reply from the hub. private Map bannerState(bridge, List hubs, List x2Hubs, int activityCount, int removedCount) { String counts = "${countText(hubs.size(), 'hub')} · ${countText(activityCount, 'activity', 'activities')}" + (removedCount ? " · ${removedCount} removed from hub" : "") if (!hubs) { return [ok: false, title: "Setup required", summary: "Add a hub to get started. Tips & Troubleshooting has the full walkthrough."] } String mqtt = bridge?.currentValue("mqttStatus") if (x2Hubs && mqtt == "broker off") { return [ok: false, title: "MQTT broker is off", summary: "${counts} · Enable Hubitat's built-in broker (Tips, X2 setup step 1)."] } if (x2Hubs && mqtt in ["error", "connect failed"]) { return [ok: false, title: "MQTT connection problem", summary: "${counts} · Check the broker settings, then run Force Reconnect on the Bridge device."] } def waiting = x2Hubs.findAll { !it.currentValue("lastMqttMessage") } if (waiting) { String names = waiting.collect { it.getLabel() }.join(", ") return [ok: false, title: "Waiting for your X2", summary: "${counts} · No reply yet from ${names}. Check it's connected to the broker (Tips, X2 setup step 2), then tap Refresh Activities."] } return [ok: true, title: "Integration ready", summary: counts] } private String hubCardHtml(hub, List activities) { String model = hub.currentValue("hubModel") ?: "unknown" boolean x2 = model == "X2" String hubLabel = hub.getLabel() String idShown = x2 ? (hub.currentValue("remoteMac") ?: "no MAC set") : (hub.currentValue("remoteIp") ?: "no IP set") StringBuilder card = new StringBuilder() card << "
" String badge = x2 ? "#5f8b6f" : "#e8a33d" // matches the Remote driver's pills card << "
${model} " card << "${hubLabel}" + " " card << "${idShown}
" if (x2) { String seen = hub.currentValue("lastMqttMessage") card << "
" card << (seen ? "Last heard from hub: ${seen}" : "Waiting for the hub to reply") << "
" } if (!activities) { card << "
" card << (x2 ? "Loading activities from the hub. Reload this page in a few seconds." : "No activities yet. Tap Add an Activity.") << "
" } else { card << "
" activities.each { act -> card << activityPillHtml(act, x2, hubLabel) } card << "
" } if (x2) card << buttonMapHtml(hub) card << "
" return card.toString() } // Opens the device in a new tab. Green dot = on. X2 shows its ID, pending state, and a flag if deleted on the hub. private String activityPillHtml(act, boolean x2, String hubLabel) { String sw = act.currentValue("switch") String status = act.currentValue("activityStatus") String dot = "" String extra = "" if (x2) { extra += " ID ${act.currentValue('sofabatonActivityId') ?: '?'}" if (status in ["starting", "stopping"]) extra += " ${status}..." if (act.currentValue("onHub") == "false") extra += " Not on hub" } else { String body = act.currentValue("bodyValue") extra += body ? " ${body}" : " No body value" if (!act.getSetting("webhookUrlOn")) extra += " Follow only" } return "${dot}${shortName(act, hubLabel)}${extra}" + "" } // X2 remote buttons: which device they come from and the number-to-name map. private String buttonMapHtml(hub) { Map btns = [:] Integer devId = null Map devs = [:] try { btns = hub.getButtonMap() ?: [:] devId = hub.getButtonDeviceId() devs = hub.getHubDevices() ?: [:] } catch (e) { } StringBuilder out = new StringBuilder("
") if (devId == null) { out << "Remote buttons: not set up. Press a Hubitat Control button on the remote (Tips, X2 setup step 4)." } else { String devName = devs[devId.toString()] ?: "device ${devId}" out << "Remote buttons from ${devName}" if (!btns) { out << "
Loading button names from the hub. Tap Refresh Activities if they don't appear.
" } else { out << "
" String dni = hub.deviceNetworkId btns.sort { a, b -> (a.key as Integer) <=> (b.key as Integer) }.each { k, v -> String assigned = buttonAssignmentText(dni, k.toString()) out << "${k} · ${v}" + (assigned ? " → ${assigned}" : " (not assigned)") + "" } out << "
" } } out << "
" return out.toString() } private String mqttStatusHtml(bridge, List x2Hubs) { String status = bridge?.currentValue("mqttStatus") ?: "not connected" String tone = status == "connected" ? "p-tag p-tag-success" : status in ["error", "connect failed", "broker off"] ? "p-tag p-tag-danger" : "surface-ground text-color-secondary" Boolean broker = null try { broker = bridge?.checkBuiltInBroker() } catch (e) { } String attr = bridge?.currentValue("brokerRunning") if (broker == null && attr in ["true", "false"]) broker = (attr == "true") String brokerText = broker == null ? (attr == "error" ? "Check failed, see logs" : "Unknown") : (broker ? "Running" : "Not running (fine if you use an external broker)") String brokerRow = "
Built-in broker" + "${brokerText}
" String rows = brokerRow + x2Hubs.collect { h -> "
${h.getLabel()}" + "${h.currentValue('lastMqttMessage') ?: 'No reply yet'}
" }.join("") """
MQTT ${status}
${rows}
""" } // ============================================================================ // Button Actions (X2 remote buttons -> switches) // ============================================================================ private List buttonDevices(String dni, String btn) { return (settings["btnDev_${dni}_${btn}"] ?: []).findAll { it != null } } private String buttonAction(String dni, String btn) { return settings["btnAct_${dni}_${btn}"] ?: "toggle" } // "Lamp, Fan (toggle)" or "Lamp +2 more (on)". Empty if nothing is assigned. private String buttonAssignmentText(String dni, String btn) { List devs = buttonDevices(dni, btn) if (!devs) return "" String names = devs.size() <= 2 ? devs*.displayName.join(", ") : "${devs[0].displayName} +${devs.size() - 1} more" return "${names} (${buttonAction(dni, btn)})" } def buttonActionsPage(params = null) { if (params?.hub) state.buttonActionsHub = params.hub String dni = state.buttonActionsHub def hub = dni ? getBridge()?.getChildDevice(dni) : null Map btns = [:] try { btns = hub?.getButtonMap() ?: [:] } catch (e) { } dynamicPage(name: "buttonActionsPage", title: hub ? "Button Actions: ${hub.getLabel()}" : "Button Actions", install: false, uninstall: false) { if (!hub) { section { paragraph "Hub not found. Go back and pick a hub again." } return } if (!btns) { section { paragraph rawHtml: true, warningMessageHtml("No buttons yet. Set up the Hubitat Control remote in the Sofabaton app " + "(Tips, X2 setup step 4), then press one of its buttons once. The buttons appear here automatically.") } return } section { paragraph "Pick what each remote button controls. " + "Toggle works as a group: if any are on, all turn off, otherwise all turn on. " + "For a Room Lighting scene, pick its activator device. Button events still fire on the hub device for Rule Machine or Button Controller." } btns.sort { a, b -> (a.key as Integer) <=> (b.key as Integer) }.each { k, v -> section("Button ${k}: ${v}") { input "btnDev_${dni}_${k}", "capability.switch", title: "Devices", multiple: true, required: false, submitOnChange: true, width: 8 input "btnAct_${dni}_${k}", "enum", title: "Action", options: [toggle: "Toggle", on: "Turn on", off: "Turn off"], defaultValue: "toggle", required: false, submitOnChange: true, width: 4 } } } } // Subscribes only to hubs with at least one assigned button. private void subscribeButtons() { unsubscribe() (getBridge()?.getChildDevices() ?: []).each { hub -> String prefix = "btnDev_${hub.deviceNetworkId}_".toString() if (settings.keySet().any { it.startsWith(prefix) && settings[it] }) subscribe(hub, "pushed", "hubButtonHandler") } } def hubButtonHandler(evt) { def hub = evt.device String dni = hub.deviceNetworkId String btn = evt.value?.toString() List devs = buttonDevices(dni, btn) if (!devs) return String action = buttonAction(dni, btn) if (action == "toggle") action = devs.any { it.currentValue("switch") == "on" } ? "off" : "on" devs.each { d -> try { if (action == "on") d.on() else d.off() } catch (e) { logErr "button ${btn} on ${hub.getLabel()}: couldn't turn ${action} ${d.displayName}: ${e.message}" } } logInfo "${hub.getLabel()} button ${btn}: turned ${action} ${devs*.displayName.join(', ')}" } private void clearButtonSettings(String dni) { settings.keySet().findAll { it.startsWith("btnDev_${dni}_".toString()) || it.startsWith("btnAct_${dni}_".toString()) }.toList().each { app.removeSetting(it) } } // ============================================================================ // Add / Edit Hub // ============================================================================ def addHubPage(params = [:]) { logDbg "addHubPage() name=${newHubName}, model=${newHubModel}, editing=${state.editingHubDni}, saveRequested=${state.hubSaveRequested}" if (state.hubPageCancelled) { ["hubPageCancelled", "editingHubDni", "editHubPrefilled", "hubSaveRequested", "discoveryStarted", "findError"].each { state.remove(it) } clearHubSettings() return mainPage() } if (params?.editDni) { state.editingHubDni = params.editDni state.remove("editHubPrefilled") } String editingDni = state.editingHubDni def bridge = getBridge() def editingHub = editingDni ? bridge?.getChildDevice(editingDni) : null if (editingHub && !state.editHubPrefilled) { app.updateSetting("newHubName", [value: editingHub.getLabel(), type: "text"]) app.updateSetting("newHubModel", [value: editingHub.currentValue("hubModel"), type: "enum"]) if (editingHub.currentValue("hubModel") == "X2") { app.updateSetting("newHubBuiltIn", [value: editingHub.currentValue("mqttBroker") != "external", type: "bool"]) app.updateSetting("newHubMqttHost", [value: editingHub.currentValue("mqttHost") ?: "", type: "text"]) app.updateSetting("newHubMqttPort", [value: editingHub.currentValue("mqttPort") ?: "1883", type: "text"]) app.updateSetting("newHubMqttUser", [value: editingHub.currentValue("mqttUser") ?: "", type: "text"]) Integer bd = null try { bd = editingHub.getButtonDeviceId() } catch (e) { } app.updateSetting("newHubButtonDev", [value: bd != null ? bd.toString() : "auto", type: "enum"]) } state.editHubPrefilled = true } boolean builtIn = newHubBuiltIn != false String pickedMac = newHubMacPick ?: newHubMac String saveError = null if (state.hubSaveRequested) { state.remove("hubSaveRequested") if (!editingHub && newHubName && newHubModel == "X1S" && newHubIp) { logInfo "adding X1S hub '${newHubName}'" createHttpHub(newHubName, newHubIp) clearHubSettings() return mainPage() } else if (!editingHub && newHubName && newHubModel == "X2" && pickedMac && (builtIn || newHubMqttHost)) { logInfo "adding X2 hub '${newHubName}'" createMqttHub(newHubName, pickedMac, builtIn, newHubMqttHost, newHubMqttPort ?: "1883", newHubMqttUser, newHubMqttPass) clearHubSettings() return mainPage() } else if (editingHub && newHubName && (editingHub.currentValue("hubModel") == "X1S" || builtIn || newHubMqttHost)) { logInfo "updating hub '${editingDni}'" updateExistingHub(editingHub, newHubName, builtIn, newHubMqttHost, newHubMqttPort ?: "1883", newHubMqttUser, newHubMqttPass, newHubButtonDev) clearHubSettings() state.remove("editingHubDni") state.remove("editHubPrefilled") return mainPage() } else { saveError = "Please fill in all required fields before saving." } } String editModel = editingHub?.currentValue("hubModel") boolean showX2 = (!editingHub && newHubModel == "X2") || editModel == "X2" boolean hubReady = editingHub ? (newHubName && (editModel == "X1S" || builtIn || newHubMqttHost)) as boolean : (newHubName && ((newHubModel == "X1S" && newHubIp) || (newHubModel == "X2" && pickedMac && (builtIn || newHubMqttHost)))) as boolean dynamicPage(name: "addHubPage", title: editingHub ? "Edit ${editingHub.getLabel()}" : "Add a Sofabaton Hub", install: false, uninstall: false) { section { paragraph rawHtml: true, HIDE_DONE_CSS input name: "newHubName", type: "text", title: "Hub Name (e.g. Living Room)", submitOnChange: true paragraph "Activities are named after the hub, e.g. Living Room-Watch TV, so they group together in device pickers." if (editingHub) { paragraph "Model: ${editModel} (remove and re-add to change)" } else { input name: "newHubModel", type: "enum", title: "Hub Model", options: ["X1S", "X2"], submitOnChange: true } } if (!editingHub && newHubModel == "X1S") { section { input name: "newHubIp", type: "text", title: "Hub IP Address (set a DHCP reservation first)", submitOnChange: true } } if (editModel == "X1S") { section { paragraph "IP Address: ${editingHub.currentValue('remoteIp') ?: 'not set'} (remove and re-add to change)" } } if (showX2) { section("Broker") { paragraph rawHtml: true, warningMessageHtml("Do X2 setup steps 1 and 2 first. Enable Hubitat's MQTT broker, then connect the X2 to it in the Sofabaton app. " + "Both are in Tips & Troubleshooting.") input name: "newHubBuiltIn", type: "bool", title: "Use Hubitat's built-in MQTT broker (recommended)", defaultValue: true, submitOnChange: true if (builtIn) { paragraph "Hubitat connects to its own broker automatically. No login needed here." } else { input name: "newHubMqttHost", type: "text", title: "Broker Host/IP", submitOnChange: true input name: "newHubMqttPort", type: "text", title: "Broker Port", defaultValue: "1883", required: false input name: "newHubMqttUser", type: "text", title: "Broker Username (blank if none)", required: false input name: "newHubMqttPass", type: "password", title: "Broker Password (blank if none${editingHub ? ', or to keep the current one' : ''})", required: false } paragraph "All X2 hubs share one MQTT connection." } if (editingHub) { section { paragraph "MAC ID: ${editingHub.deviceNetworkId} (remove and re-add to change)" } section("Remote buttons") { Map devs = [:] try { devs = editingHub.getHubDevices() ?: [:] } catch (e) { } Map opts = [auto: "Auto (learn from the first button press)"] devs.each { k, v -> opts[k.toString()] = "${v} (ID ${k})".toString() } input name: "newHubButtonDev", type: "enum", title: "Hubitat Control device", options: opts, submitOnChange: true paragraph "The Home Assistant Remote you made in the Sofabaton app for Hubitat buttons (Tips, X2 setup step 4). " + "Leave on Auto unless the wrong device was learned." } } else { section("Find your X2") { paragraph "1. Tap ${state.discoveryStarted ? 'Check Again' : 'Find My X2'}. " + "2. On the Sofabaton remote, start or switch any activity. 3. Tap Check Again." if (state.findError) paragraph "${state.findError}" input name: "findX2Btn", type: "button", title: state.discoveryStarted ? "Check Again" : "Find My X2" List found = bridge?.getDiscoveredMacs() ?: [] if (found) { input name: "newHubMacPick", type: "enum", title: "X2 hubs found", options: found, submitOnChange: true } else if (state.discoveryStarted) { paragraph "Nothing yet. Volume and channel presses don't count, it has to be an activity change." } input name: "newHubMac", type: "text", title: "Or enter the MAC manually (optional)", required: false, submitOnChange: true } } } section { if (saveError) paragraph "${saveError}" formButtons("Hub", editingHub ? "Save Changes" : "Add This Hub", hubReady) } } } private void clearHubSettings() { ["newHubName", "newHubModel", "newHubIp", "newHubMac", "newHubMacPick", "newHubBuiltIn", "newHubMqttHost", "newHubMqttPort", "newHubMqttUser", "newHubMqttPass", "newHubButtonDev"].each { app.removeSetting(it) } } // Password only overwritten when a new one is typed. A rename relabels default-named activities. private void updateExistingHub(def hub, String name, boolean builtIn, String mqttHost, String mqttPort, String mqttUser, String mqttPass, String buttonDev = null) { String oldLabel = hub.getLabel() hub.setLabel(name) if (oldLabel && oldLabel != name) { try { hub.applyActivityLabels(oldLabel, name) } catch (e) { logErr "failed to relabel activities after renaming '${oldLabel}': ${e.message}" } } if (hub.currentValue("hubModel") == "X2") { if (buttonDev) { try { hub.setButtonDevice(buttonDev == "auto" ? null : (buttonDev as Integer)) } catch (e) { logErr "failed to set the Hubitat Control device: ${e.message}" } } hub.updateSetting("useBuiltInBroker", [value: builtIn, type: "bool"]) if (!builtIn) { hub.updateSetting("mqttHost", [value: mqttHost, type: "text"]) hub.updateSetting("mqttPort", [value: mqttPort, type: "text"]) hub.updateSetting("mqttUser", [value: mqttUser ?: "", type: "text"]) if (mqttPass) hub.updateSetting("mqttPass", [value: mqttPass, type: "password"]) } hub.updated() } } private void createHttpHub(String name, String ip) { def bridge = getBridge() if (!bridge) return String dni = ipToHexForApp(ip) if (!dni) { logErr "cannot add hub '$name': '$ip' is not a valid IPv4 address" return } def hub = bridge.createRemoteDevice(dni, name) if (!hub) { logErr "failed to create Remote device for hub '$name'" return } hub.updateSetting("ip", [value: ip, type: "text"]) hub.updateSetting("hubModel", [value: "X1S", type: "enum"]) hub.updated() } // Remote.updated() connects MQTT and requests the activity list. private void createMqttHub(String name, String mac, boolean builtIn, String host, String port, String user, String pass) { def bridge = getBridge() if (!bridge) { logErr "cannot add hub '$name': Bridge device not found" return } String dni = (mac ?: "").replaceAll(/[^A-Fa-f0-9]/, "").toUpperCase() if (dni.length() != 12) { logErr "cannot add hub '$name': '$mac' is not a valid 12-character MAC" return } def hub = bridge.createRemoteDevice(dni, name) if (!hub) { logErr "failed to create Remote device for hub '$name' (check Bridge logs)" return } hub.updateSetting("hubModel", [value: "X2", type: "enum"]) hub.updateSetting("mac", [value: dni, type: "text"]) hub.updateSetting("useBuiltInBroker", [value: builtIn, type: "bool"]) if (!builtIn) { hub.updateSetting("mqttHost", [value: host, type: "text"]) hub.updateSetting("mqttPort", [value: port, type: "text"]) if (user) hub.updateSetting("mqttUser", [value: user, type: "text"]) if (pass) hub.updateSetting("mqttPass", [value: pass, type: "password"]) } hub.updated() pauseExecution(1500) // let the first list reply land before mainPage redraws logDbg "createMqttHub() done, DNI $dni" } // ============================================================================ // Add / Edit Activity (X1S only. X2 activities come from the hub.) // ============================================================================ def addActivityPage() { logDbg "addActivityPage() hub=${newActivityHub}, name=${newActivityName}, saveRequested=${state.activitySaveRequested}, editing=${state.editingActivityKey}" if (state.activityPageCancelled) { ["activityPageCancelled", "editingActivityKey", "editActivityPrefilled"].each { state.remove(it) } clearActivitySettings() return mainPage() } def bridge = getBridge() def x1sHubs = (bridge?.getChildDevices() ?: []).findAll { it.currentValue("hubModel") != "X2" } // state.editingActivityKey = "|", set by the Edit button. String editingHubDni = null def editingActivity = null def editingHub = null if (state.editingActivityKey) { def keyParts = state.editingActivityKey.split(/\|/, 2) if (keyParts.length == 2) { editingHubDni = keyParts[0] editingHub = bridge?.getChildDevice(editingHubDni) editingActivity = editingHub?.getChildDevice(keyParts[1]) } } if (editingActivity && !state.editActivityPrefilled) { app.updateSetting("newActivityHub", [value: editingHubDni, type: "enum"]) app.updateSetting("newActivityName", [value: editingActivity.currentValue("sofabatonName") ?: shortName(editingActivity, editingHub?.getLabel()), type: "text"]) app.updateSetting("newActivityUrlOn", [value: editingActivity.getSetting("webhookUrlOn") ?: "", type: "text"]) app.updateSetting("newActivityUrlOff", [value: editingActivity.getSetting("webhookUrlOff") ?: "", type: "text"]) app.updateSetting("newActivityBody", [value: editingActivity.currentValue("bodyValue") ?: "", type: "text"]) state.editActivityPrefilled = true } def selectedHub = newActivityHub ? bridge?.getChildDevice(newActivityHub) : null // Suggest a body value from the name until the user sets one. if (newActivityName && !newActivityBody) { app.updateSetting("newActivityBody", [value: suggestBody(newActivityName), type: "text"]) } String body = (settings.newActivityBody ?: "").trim() def clash = selectedHub?.getChildDevices()?.find { it.deviceNetworkId != editingActivity?.deviceNetworkId && body && it.currentValue("bodyValue")?.equalsIgnoreCase(body) } List missing = [] if (!editingActivity && !newActivityHub) missing << "Sofabaton Hub" if (!newActivityName) missing << "Activity Name" if (!body) missing << "Body Value" String saveError = null if (state.activitySaveRequested) { state.remove("activitySaveRequested") if (clash) { saveError = "Body value '${body}' is already used by ${clash.getLabel()}. Pick a different one." } else if (missing) { saveError = "Still needed: ${missing.join(', ')}." } else if (editingActivity) { updateExistingActivity(editingActivity, editingHub, newActivityName, newActivityUrlOn, newActivityUrlOff, body) clearActivitySettings() state.remove("editingActivityKey") state.remove("editActivityPrefilled") return mainPage() } else { createActivity(newActivityHub, newActivityName, newActivityUrlOn, newActivityUrlOff, body) clearActivitySettings() return mainPage() } } dynamicPage(name: "addActivityPage", title: editingActivity ? "Edit ${editingActivity.getLabel()}" : "Add an X1S Activity", install: false, uninstall: false) { section { paragraph rawHtml: true, HIDE_DONE_CSS if (editingActivity) { paragraph "Hub: ${selectedHub?.getLabel() ?: editingHubDni} (remove and re-add to move it)" } else { input name: "newActivityHub", type: "enum", title: "Sofabaton Hub", options: x1sHubs.collectEntries { [(it.deviceNetworkId): it.getLabel()] }, submitOnChange: true } input name: "newActivityName", type: "text", title: "Activity Name (e.g. Watch TV)", submitOnChange: true if (selectedHub && newActivityName) { paragraph "Device name: ${selectedHub.getLabel()}-${newActivityName}" } } if (selectedHub) { section { paragraph rawHtml: true, groupHeaderHtml("SOFABATON → HUBITAT", "#26897a", "Sent by the Sofabaton IP control device when this activity starts. Filled in from the name.") input name: "newActivityBody", type: "text", title: "Body Value", submitOnChange: true if (body) paragraph rawHtml: true, "
In the Sofabaton app, set the body to " + "${body} " + "(X1S setup, step 3)
" } section { paragraph rawHtml: true, groupHeaderHtml("HUBITAT → SOFABATON", "#e8a33d", "Optional. Lets Hubitat start and stop this activity. From Turn on API in the Sofabaton app (X1S setup, step 1). " + "Leave blank if Hubitat only needs to follow the activity.") input name: "newActivityUrlOn", type: "text", title: "Start Webhook URL (optional)", submitOnChange: true input name: "newActivityUrlOff", type: "text", title: "Stop Webhook URL (optional)", required: false } } section { if (saveError) paragraph "${saveError}" boolean activityReady = (!missing && !clash) as boolean formButtons("Activity", editingActivity ? "Save Changes" : "Add This Activity", activityReady) } } } // Cancel and Save side by side at the bottom of Add/Edit pages, as links to mainPage styled // as buttons (see HIDE_DONE_CSS). Save is gray until required fields are filled; pressing it // anyway just shows what's missing. private void formButtons(String kind, String saveTitle, boolean ready) { if (!state.formToken) state.formToken = now().toString() href name: "sbCancel${kind}", title: "Cancel", description: "", page: "mainPage", params: [formAction: "cancel${kind}", token: state.formToken], width: 3 // "Ready" in the name lets HIDE_DONE_CSS color it green (href style lands on an outer box). href name: "sbSave${kind}${ready ? 'Ready' : ''}", title: saveTitle, description: "", page: "mainPage", params: [formAction: "save${kind}", token: state.formToken], width: 3 } // Small colored pill plus one line of help, used as a group header on the Activity page. private String groupHeaderHtml(String pill, String color, String help) { "
" + "${pill}" + "${help}
" } private void clearActivitySettings() { ["newActivityHub", "newActivityName", "newActivityUrlOn", "newActivityUrlOff", "newActivityBody"].each { app.removeSetting(it) } } // "Watch Apple TV" -> "watchappletv". Letters and numbers only, so it's easy to type in the Sofabaton app. private String suggestBody(String name) { return (name ?: "").toLowerCase().replaceAll(/[^a-z0-9]/, "") } private void createActivity(String hubDni, String name, String urlOn, String urlOff, String body) { def hub = getBridge()?.getChildDevice(hubDni) if (!hub) { logErr "cannot add activity '$name': hub not found" return } if (!hub.createActivityDevice(name, urlOn, urlOff, null, body)) { logErr "failed to create Activity device '$name'" } else { logInfo "added activity '$name'" } } private void updateExistingActivity(def activity, def hub, String name, String urlOn, String urlOff, String body) { activity.setLabel(hub ? "${hub.getLabel()}-${name}".toString() : name) activity.setHubInfo(name, true) activity.updateSetting("webhookUrlOn", [value: urlOn ?: "", type: "text"]) activity.updateSetting("webhookUrlOff", [value: urlOff ?: "", type: "text"]) activity.updateSetting("bodyValue", [value: body, type: "text"]) activity.updated() } // ============================================================================ // Buttons // ============================================================================ def appButtonHandler(String btn) { logDbg "button: ${btn}" def bridge = getBridge() if (btn == "saveHubBtn") { state.hubSaveRequested = true; return } if (btn == "cancelHubBtn") { state.remove("editingHubDni") state.remove("editHubPrefilled") state.hubPageCancelled = true return } if (btn == "findX2Btn") { state.remove("findError") boolean builtIn = newHubBuiltIn != false if (!builtIn && !newHubMqttHost) { state.findError = "Enter the broker host first." return } bridge?.startDiscovery(builtIn, newHubMqttHost, newHubMqttPort ?: "1883", newHubMqttUser, newHubMqttPass) state.discoveryStarted = true return } if (btn.startsWith("editHub_")) { state.editingHubDni = btn - "editHub_" state.remove("editHubPrefilled") return } if (btn.startsWith("syncHub_")) { bridge?.getChildDevice(btn - "syncHub_")?.requestActivityList() pauseExecution(1500) // let the reply land before the page redraws return } if (btn.startsWith("removeHub_")) { String dni = btn - "removeHub_" bridge?.removeRemoteDevice(dni) clearButtonSettings(dni) subscribeButtons() return } if (btn == "cancelActivityBtn") { state.activityPageCancelled = true; return } if (btn.startsWith("editAct_")) { def parts = (btn - "editAct_").split("_", 2) if (parts.length == 2) { state.editingActivityKey = "${parts[0]}|${parts[1]}" state.remove("editActivityPrefilled") } return } if (btn.startsWith("removeAct_")) { def parts = (btn - "removeAct_").split("_", 2) if (parts.length == 2) bridge?.getChildDevice(parts[0])?.removeActivityDeviceByDni(parts[1]) return } if (btn == "saveActivityBtn") { state.activitySaveRequested = true; return } } // Mirrors RemoteDriver's ipToHex(). Keep in sync. private String ipToHexForApp(String ipAddress) { List quad = (ipAddress ?: "").trim().split(/\./) boolean valid = quad.size() == 4 && quad.every { it.isInteger() && it.toInteger() >= 0 && it.toInteger() <= 255 } if (!valid) return null return quad.collect { Integer.toHexString(it.toInteger()).padLeft(2, "0").toUpperCase() }.join() } // ============================================================================ // Tips & Troubleshooting content. This is the only setup guide; the README links here. // ============================================================================ private String exampleBoxHtml(String content) { "
${content}
" } private List tipsTopics() { [ [id: "start", label: "Before you begin", title: "Before you begin", group: "Getting started", icon: "pi-sitemap", body: "

Find your hub below, then follow its setup in the sidebar.

" + "" + "" + "" + "" + "" + "" + "
HubHubitat knows the activityHubitat starts/stops itActivities
X2Yes, localYes, localAdded automatically
X1SYes, localOptional, through Sofabaton's cloudAdded by you
X1Not supported. See Common questions.
" + "

Setup moves back and forth between two apps, so each step is tagged:

" + "

${HUBITAT_PILL}
This Hubitat app or a device page.

" + "

${SOFABATON_PILL}
The Sofabaton mobile app.

", checklist: [[title: "Know your model", detail: "It's printed on the hub, or shown in the Sofabaton app under the hub's settings."], [title: "X2: Hubitat 2.5.2.126 or newer", detail: "Earlier versions can't receive MQTT messages. Check Settings → Hub Details."], [title: "X2: Hubitat's MQTT broker", detail: "Enabled through MQTT Import Integration. See X2 setup, step 1."], [title: "X1S: a static IP", detail: "Set a DHCP reservation on your router. If the IP changes later, the integration stops working with no error."]]], [id: "x2broker", label: "1. Enable the MQTT broker", title: "Enable Hubitat's MQTT broker", group: "X2 setup", icon: "pi-server", body: "

${HUBITAT_PILL}
Go to Integrations → Add Built-In App → MQTT Import Integration. Enable it and turn on Use built-in MQTT service.

" + "

Write down the port, username, and password it shows. You'll need them once, in the Sofabaton app. This app connects to the broker on its own.

"], [id: "x2connect", label: "2. Connect the X2", title: "Point the X2 at the broker", group: "X2 setup", icon: "pi-wifi", body: "

${SOFABATON_PILL}
Tap Me → Connect to Home Assistant (MQTT broker) → Confirmed MQTT Installed. " + "Enter your Hubitat hub's IP (${location.hub.localIP}), then the port, username, and password from step 1. " + "If it asks for Home Assistant account credentials, that's just the broker username and password. Confirm it shows connected before moving on.

", warning: "Easy to skip, and nothing works without it.
Ignore the Home Assistant wording, you're pointing it at Hubitat's broker. " + "Devices → Add Device → Wi-Fi → Home Assistant Remote is a different feature, used later for remote buttons (step 4). Don't use it for this step."], [id: "x2add", label: "3. Add the hub", title: "Add the X2 hub", group: "X2 setup", icon: "pi-plus-circle", body: "

${HUBITAT_PILL}
Add a Hub → Model X2 → leave Use Hubitat's built-in MQTT broker on.

" + "

${HUBITAT_PILL}
Tap Find My X2. On the Sofabaton remote, start or switch any activity, then tap Check Again. Pick your hub from the list and tap Add This Hub.

" + "

Your activities appear automatically within a few seconds, named after the hub, e.g. Living Room-Watch Apple TV, " + "so they group together in Room Lighting and other device pickers. Tap Done on the main page when finished.

" + exampleBoxHtml("Added or renamed an activity in the Sofabaton app?
Tap Refresh Activities on the hub. " + "Names you set in Hubitat are never overwritten."), warning: "Find My X2 needs an activity change.
Volume, channel, and other button presses never touch the network, so they won't show up."], [id: "x2buttons", label: "4. Remote buttons (optional)", title: "Control Hubitat with remote buttons", group: "X2 setup", icon: "pi-th-large", body: "

Turn spare keys on the remote (A, B, C, or a color key) into buttons that control anything in Hubitat.

" + "

${SOFABATON_PILL}
Devices → Add Device → Wi-Fi → Home Assistant Remote. Name it (e.g. Hubitat Control) " + "and add a command for each button you want (e.g. HE Button 1, HE Button 2). The topic and payload are filled in for you.

" + "

${SOFABATON_PILL}
Add Hubitat Control to an activity, then Customize remote buttons → pick an unused key → " + "Short press and/or Long press → Normal Key → Device Hubitat Control → Command HE Button 1. " + "Repeat in every activity where the key should work.

" + "

${HUBITAT_PILL}
Press the key once. The hub learns Hubitat Control, loads its commands, and shows them on the main page, " + "e.g. 1 · HE Button 1. The command number is the button number.

" + "

${HUBITAT_PILL}
Tap Button Actions under the hub and pick what each button controls, with Toggle, Turn on, or Turn off. " + "For a Room Lighting scene, pick its activator device.

" + "

Need something fancier? Rule Machine and Button Controller can use the hub device (e.g. Living Room) as a button device too.

" + exampleBoxHtml("Example, a lamp on key A:
" + "• Key A short press = HE Button 1
" + "• Button Actions: HE Button 1 → Living Room Lamp, Toggle
" + "Want a dedicated off? Set key A long press to HE Button 3, then HE Button 3 → the lamp, Turn off."), warning: "Keys only send while you're in an activity they're assigned in. Added or renamed a command? Tap Refresh Activities."], [id: "x1swebhook", label: "1. Webhook URL (optional)", title: "Get the activity's webhook URL (optional)", group: "X1S setup", icon: "pi-link", body: "

${SOFABATON_PILL}
Open the activity, turn on Turn on API (just API in some app versions), and copy the webhook URL, " + "a long https://app1.sofabaton.com/... link. Some versions give separate start and stop URLs, others just one.

" + "

This is how Hubitat starts and stops the activity. Skip it if Hubitat only needs to know which activity is running, " + "for example to dim lights when a movie starts. Those activities show Follow only on the main page.

"], [id: "x1sadd", label: "2. Add hub and activities", title: "Add the X1S hub and its activities", group: "X1S setup", icon: "pi-plus-circle", body: "

${HUBITAT_PILL}
Add a Hub → Model X1S → enter the hub's static IP.

" + "

${HUBITAT_PILL}
Add an Activity → pick the hub, name it, and paste the webhook URL from step 1 if you got one. " + "A space in the URL is normal, it's encoded automatically.

" + "

Note the Body Value it suggests, e.g. watchappletv. You'll need it in step 3. Tap Done on the main page when finished.

"], [id: "x1sreport", label: "3. Report activity changes", title: "Let the X1S report activity changes", group: "X1S setup", icon: "pi-mobile", body: "

${SOFABATON_PILL}
Devices → Add Device → Wi-Fi → Create a virtual device for IP control. " + "URL http://${location.hub.localIP}:39501/, method PUT, body = the activity's Body Value.

" + "

${SOFABATON_PILL}
Add that command to the activity's start sequence, so the hub tells Hubitat whenever the activity starts. Repeat for each activity.

" + exampleBoxHtml("Example, an Apple TV activity:
" + "• Activity name in Hubitat: Watch Apple TV
" + "• Body Value (shown on its pill on the main page): watchappletv
" + "• IP control body in the Sofabaton app: watchappletv"), warning: "The body must match the Body Value exactly. If it doesn't, the remote still controls your gear, Hubitat just won't know the activity changed."], [id: "x1smatch", label: "4. Button events (optional)", title: "Button events (optional)", group: "X1S setup", icon: "pi-th-large", body: "

Activities sync from their Body Value alone. Button slots are only for rules that react to a button press.

" + "

Buttons 1-10 fire when the remote sends a plain number. Buttons 11-20 are slots on the hub device's Preferences tab, " + "entered as matchString|Description.

" + exampleBoxHtml("Example: a body of lightsdim and User 1 (11) set to lightsdim|Dim Lights " + "fires button 11 on the hub device, which a rule can use.")], [id: "x2timing", label: "X2 commands and timing", title: "X2 commands and timing", group: "Troubleshooting", icon: "pi-clock", body: "

The hub confirms a command only after its start or stop sequence finishes, usually 8 to 10 seconds. " + "Until then the activity's activityStatus shows starting or stopping, and the switch flips once the hub confirms. " + "If no confirmation arrives within 20 seconds, the integration resyncs from the hub.

" + "

Turning off any activity powers off the hub, the same as the remote's Power Off key. Turning off an activity that's already off does nothing. " + "To change activities, just turn on the new one.

"], [id: "x2missing", label: "X2 activities missing", title: "X2 activities missing or out of date", group: "Troubleshooting", icon: "pi-exclamation-triangle", body: "

Activity lists and button names refresh automatically every 3 hours. For an immediate update, tap Refresh Activities on the hub. If the banner says Waiting for your X2, the hub isn't replying: " + "recheck X2 setup step 2, then run Force Reconnect on the Bridge device.

" + "

An activity deleted in the Sofabaton app shows Not on hub, with a Remove button on the main page. It's never deleted automatically, so your rules don't break unexpectedly.

"], [id: "traffic", label: "What creates MQTT traffic", title: "What creates MQTT traffic", group: "Troubleshooting", icon: "pi-info-circle", body: "

From the remote: starting or switching an activity, Power Off, and keys assigned to your Hubitat Control remote (X2 setup, step 4). " + "Other button presses within an activity (volume, channel, play/pause) never touch the network.

" + "

X1S never uses MQTT. It reports over local HTTP, and takes commands through Sofabaton's cloud webhook if you added one.

"], [id: "faq", label: "Common questions", title: "Common questions", group: "Troubleshooting", icon: "pi-question-circle", body: "

Why isn't the original X1 supported?
The X1 can only send its IP control messages to port 8060, and Hubitat only listens on port 39501, " + "so an X1 can't report to Hubitat directly. The X1S and X2 don't have this limit.

" + "

Do I need the X1S webhook?
No. Without it, Hubitat still knows which activity is running (Follow only). " + "Add it if you also want Hubitat to start and stop activities.

" + "

Can I rename an activity?
Yes, on its device page. New activities are named Hub-Activity so they group together in pickers; " + "a name you set yourself is never overwritten. The Sofabaton name is kept in the sofabatonName attribute. Renaming a hub in Edit Hub updates its default-named activities.

" + "

Where does an X1S webhook URL go?
The activity's Start and Stop Webhook URL fields, when adding it or later through Edit.

" + "

Can I change a hub's model, MAC, or IP?
No, those set the device ID. Remove the hub and add it again.

"], [id: "removal", label: "Removing hubs", title: "Remove hubs and activities safely", group: "Devices", icon: "pi-trash", body: "

Use the Remove buttons on the main page, not Hubitat's Devices page. Removing a hub also removes its activities.

" + "

X2 activities come from the hub, so remove them in the Sofabaton app first. Then tap Refresh Activities and use the Remove button that appears.

"], [id: "logging", label: "Log levels", title: "Log levels", group: "Logging", icon: "pi-file", body: "

The Log level on the main page is the only logging setting. It applies to this app and every Sofabaton device.

" + "

" + logLevelPill("Errors Only") + " Default. Warnings and errors only.

" + "

" + logLevelPill("Normal") + " Errors, plus activities turning on and off, button presses, and hubs or activities added or removed.

" + "

" + logLevelPill("Full") + " Everything, including raw MQTT traffic. Use while troubleshooting. Turns itself off after 30 minutes.

" + "

Every Sofabaton log line appears under Sofabaton Integration on Hubitat's Logs page, labeled by where it came from, " + "e.g. [Bridge], [Sofabaton LR Hub], or an activity name. View logs in the banner shows all of it in one place.

"] ] } // ============================================================================ // UI template (HubitatAppUiTemplate, from Reolink v1.6.1) // ============================================================================ private void helpAndSupportSection() { section(title: "Help & Support", sectionClass: "app-ui-links app-main-support") { href name: "tips", title: "Tips & Troubleshooting", page: "tipsPage", description: "Setup guides for X2 and X1S", width: 4, style: "margin:8px;" paragraph rawHtml: true, supportLinkHtml(COMMUNITY_URL, "pi pi-comments", "Hubitat Community Thread", "Questions, feedback, and release notes"), width: 4 paragraph rawHtml: true, supportLinkHtml(COFFEE_URL, "fa-solid fa-mug-hot", "Buy Me a Coffee", "Support development"), width: 4 } } private void versionFooterSection() { section { paragraph "
${APP_NAME} v${APP_VERSION}
" } } private String supportLinkHtml(String url, String iconClass, String title, String subtitle) { """ ${title} ${subtitle} """ } private String statusBannerHtml(boolean ok, String title, String summary) { String tone = ok ? "p-message p-message-success app-message" : "p-message p-message-warn app-message" String icon = ok ? "pi pi-check-circle hub-text-success" : "fa-solid fa-exclamation-triangle hub-text-warning" """
${title}
${summary}
View logs
""" } private String columnHeader(String title) { "
${title}
" } private String countText(int count, String singular, String plural = null) { "${count} ${count == 1 ? singular : (plural ?: singular + 's')}" } private String loggingDetailsPopupHtml() { String current = currentLogLevel() String details = """
Logging levels
One setting for the app and every Sofabaton device.
${logLevelDetailHtml("Errors Only", "Warnings and errors only.", current)} ${logLevelDetailHtml("Normal", "Errors, plus activities turning on and off, button presses, and hubs or activities added or removed.", current)} ${logLevelDetailHtml("Full", "Everything, including raw MQTT traffic. Use while troubleshooting. Turns off after 30 minutes.", current)}
""" String js = groovy.json.JsonOutput.toJson(details) .replace("&", "&").replace("'", "'").replace("<", "<").replace(">", ">") """ Logging details What each level records """ } private String logLevelDetailHtml(String name, String description, String current) { String badge = name == current ? "Current" : "" String tone = name == "Errors Only" ? "p-tag p-tag-danger" : name == "Full" ? "p-tag p-tag-warning" : "p-tag p-tag-info" """
${name}${name == "Errors Only" ? " (default)" : ""} ${badge}
${description}
""" } def tipsPage(params = null) { def topics = tipsTopics() def topic = topics.find { it.id == params?.topic } ?: topics.find { it.id == DEFAULT_TIP_TOPIC } ?: topics[0] int i = topics.indexOf(topic) def next = i + 1 < topics.size() ? topics[i + 1] : null dynamicPage(name: "tipsPage", title: "Tips & Troubleshooting") { section(sectionClass: "app-ui-links app-tips-index app-ui-navigation") { paragraph rawHtml: true, tipsStylesHtml() topics.groupBy { it.group }.each { group, entries -> paragraph rawHtml: true, "
${group}
" entries.each { entry -> String selected = entry.id == topic.id ? "app-ui-navigation-selected" : "" href name: "tip_${entry.id}", title: "${entry.label}", description: "", page: "tipsPage", params: [topic: entry.id], width: 12, style: "margin:0 8px;" } } } section(sectionClass: "app-ui-links app-tips-article") { paragraph rawHtml: true, tipsArticleHtml(topic) if (topic.append) { def extra = topics.find { it.id == topic.append } if (extra) paragraph rawHtml: true, tipsArticleHtml(extra) } if (next) { href name: "nextTip", title: "${next.label}", description: "Next topic", page: "tipsPage", params: [topic: next.id], width: 12, style: "margin:8px;" } } } } private String tipsArticleHtml(Map topic) { String extra = "" if (topic.checklist) { extra += "
" + topic.checklist.collect { item -> "
" + "
${item.title}
${item.detail}
" }.join("") + "
" } if (topic.warning) extra += warningMessageHtml(topic.warning) """

${topic.title}

${topic.body}${extra}
""" } private String tipsStylesHtml() { """ """ } private String logLevelPill(String level) { def tones = ["Errors Only": "p-tag p-tag-danger", "Normal": "surface-ground text-color-secondary", "Full": "p-tag p-tag-warning"] "${level}" } private String warningMessageHtml(String content, String extraClasses = "") { "
" + "
" + "
${content}
" } private String appPageSpacingCss() { """ ${tileSecondaryTextCss()} .app-heading { font-size: 16px; } .p-message.app-message { padding: 0.75rem !important; margin: 0; border: 0 !important; } .app-message .text-color-secondary, .app-message .hub-text-link, .app-message .hub-text-warning, .app-message .hub-text-success, .app-message .hub-text-danger { color: inherit !important; } div.panel-body { padding: 0 !important; margin-left: -0.5em; margin-right: -0.5em; } fieldset#fieldsetAppButtons { margin-left: 0.5em; margin-right: 0.5em; } """ } // Light fallback for firmware without the hub's semantic app classes (pre-2.5.3); scoped to this app's form // and logging dialog. Capable firmware marks data-hubitat-app-ui-semantics="1" and these rules step aside. private String legacyLightThemeCss() { """ :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .hub-text-link { color: var(--hubitat-link-color, #1565c0) !important; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .hub-text-success { color: var(--hubitat-success-color, #356700) !important; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .hub-text-warning { color: var(--hubitat-warning-color, #8a5a00) !important; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .hub-text-danger { color: var(--hubitat-validation-color, #b71c1c) !important; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .app-ui-links .hrefElem { color: var(--hubitat-link-color, #1565c0) !important; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .app-ui-links .hrefElem > .state-incomplete-text, :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .app-ui-links .hrefElem > .state-complete-text { color: var(--text-color-secondary, #616161); } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) button.mdl-button.app-ui-button { background: var(--hubitat-control-background, rgba(158,158,158,.2)); color: var(--hubitat-control-text, #363636); border: 1px solid transparent; border-radius: 4px; box-shadow: 0 2px 2px rgba(0,0,0,.14); } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) button.mdl-button.app-ui-button:enabled:hover { background: var(--hubitat-control-hover-background, #cacfdc); } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) button.mdl-button.app-ui-button-primary, :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) button.mdl-button.app-ui-button-primary:enabled:hover { background: var(--hubitat-brand-background, #81bc00); color: var(--hubitat-on-brand, #fff); } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) button.mdl-button.app-ui-button-secondary { background: var(--surface-card, #fff); color: var(--hubitat-link-color, #1565c0); border-color: var(--hubitat-control-border, #b0bec5); box-shadow: none; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) button.mdl-button.app-ui-button-danger, :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) button.mdl-button.app-ui-button-danger:enabled:hover { background: var(--hubitat-danger-background, #d32f2f); color: var(--hubitat-on-danger, #fff); } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) button.mdl-button.app-ui-button:enabled:active { box-shadow: none; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) button.mdl-button.app-ui-button:disabled { background: var(--hubitat-control-background, #eee); color: var(--text-color-secondary, #616161); opacity: .6; cursor: not-allowed; box-shadow: none; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .app-ui-navigation .hrefElem { background: transparent; color: var(--hubitat-link-color, #1565c0) !important; border: 0; border-left: 3px solid transparent; border-radius: 0; box-shadow: none; padding: 12px; font-family: inherit; font-size: 16px; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .app-ui-navigation .hrefElem::before { display: none; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .app-ui-navigation .hrefElem:not(:disabled):not(:has(.app-ui-navigation-selected)):hover { background: var(--surface-hover, #f3f6fa); } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .app-ui-navigation .hrefElem:has(.app-ui-navigation-selected) { background: var(--hubitat-selection-background, #eaf2fc); border-left-color: var(--hubitat-link-color, #1565c0); font-weight: 600; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .app-ui-navigation .hrefElem:disabled { opacity: .6; cursor: not-allowed; } :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) button.mdl-button.app-ui-button:focus-visible, :root:not(:has([data-hubitat-app-ui-semantics="1"])) :is(#formApp, .sb-dialog) .app-ui-navigation .hrefElem:focus-visible { outline: 2px solid var(--hubitat-link-color, #1565c0); outline-offset: 2px; } """ } private String tileSecondaryTextCss() { legacyLightThemeCss() + "#formApp .hrefElem > .state-incomplete-text, #formApp .hrefElem > .state-complete-text { font-size: 14px !important; }" } private String tipsCardCss() { """ button.hrefElem[name^='_action_href_tips'] { position: relative; background: var(--surface-card, #fff); border: 1px solid var(--surface-border, rgba(0,0,0,.12)); border-radius: 4px; box-shadow: none; color: var(--text-color, #333); height: 56px; font-family: inherit; font-size: 16px; font-weight: 500; line-height: 1.4; padding: 8px 28px 8px 46px; } button.hrefElem[name^='_action_href_tips'] > span:first-child { color: var(--hubitat-link-color, #1565c0); font-weight: 600; font-size: 16px !important; } button.hrefElem[name^='_action_href_tips'] > span.state-incomplete-text { color: var(--text-color-secondary, #757575); font-size: 14px; font-weight: 500; line-height: 1.4; } button.hrefElem[name^='_action_href_tips'] i.pi { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--hubitat-link-color, #1565c0); font-size: 20px; } button.hrefElem[name^='_action_href_tips']::before { color: var(--hubitat-link-color, #1565c0); } """ }