/**
* MIT License
* Copyright 2023 Daniel Winks (daniel.winks@gmail.com)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
#include dwinks.UtilitiesAndLoggingLibrary
definition(
name: 'Sonos Advanced Controller',
version: '0.9.3',
namespace: 'dwinks',
author: 'Daniel Winks',
category: 'Audio',
description: 'Sonos speaker integration with advanced functionality, such as non-interrupting announcements and grouping control',
iconUrl: '',
iconX2Url: '',
installOnOpen: false,
iconX3Url: '',
singleThreaded: true,
importUrl: 'https://raw.githubusercontent.com/DanielWinks/Hubitat-Public/main/Apps/SonosAdvancedApp.groovy'
)
preferences {
page(name: 'mainPage', install: true, uninstall: true)
page(name: 'localPlayerPage')
page(name: 'localPlayerSelectionPage')
page(name: 'groupPage')
}
// =============================================================================
// Fields
// =============================================================================
@Field static Map playerSelectionOptions = new java.util.concurrent.ConcurrentHashMap()
@Field static Map discoveredSonoses = new java.util.concurrent.ConcurrentHashMap()
@Field static Map discoveredSonosSecondaries = new java.util.concurrent.ConcurrentHashMap()
@Field static Map SOURCES = [
"\$": "None",
"x-file-cifs:": "Library",
"x-rincon-mp3radio:": "Radio",
"x-sonosapi-stream:": "Radio",
"x-sonosapi-radio:": "Radio",
"x-sonosapi-hls:": "Radio",
"x-sonos-http:sonos": "Radio",
"aac:": "Radio",
"hls-radio:": "Radio",
"https?:": "File",
"x-rincon-stream:": "LineIn",
"x-sonos-htastream:": "TV",
"x-sonos-vli:.*,airplay:": "Airplay",
"x-sonos-vli:.*,spotify:": "Spotify",
"x-rincon-queue": "Sonos Q"
]
// =============================================================================
// End Fields
// =============================================================================
// =============================================================================
// Getters and Setters
// =============================================================================
@CompileStatic
String getLocalApiPrefix(String ipAddress) {
return "https://${ipAddress}/api/v1"
}
String getLocalUpnpHostForCoordinatorId(String groupCoordinatorId) {
String localUpnpHost = app.getChildDevices().find{ cd -> cd.getDataValue('id') == groupCoordinatorId }.getDataValue('localUpnpHost')
return localUpnpHost
}
// =============================================================================
// End Getters and Setters
// =============================================================================
// =============================================================================
// App Pages
// =============================================================================
Map mainPage() {
if(state.discoveryRunning) { stopDiscovery() }
checkForUpdates()
dynamicPage(title: 'Sonos Advanced Controller') {
section {
paragraph 'This application provides Advanced Sonos Player control, including announcements and grouping.'
if(state.updateAvailable == true) {
section('⚠️ Update Available', hideable: false) {
paragraph "Version ${state.latestVersion} is available!
" +
"Current version: ${getActualInstalledAppVersion()}
" +
"Released: ${state.latestReleaseDate}
" +
"View Release Notes"
input 'btnInstallUpdate', 'button', title: 'Install Update', submitOnChange: false
input 'btnDismissUpdate', 'button', title: 'Dismiss', submitOnChange: false
}
}
href (
page: 'localPlayerPage',
title: 'Sonos Virtual Player Devices',
description: 'Select to create Sonos player devices using local discovery'
)
href (
page: 'groupPage',
title: 'Sonos Virtual Group Devices',
description: 'Select to create/delete Sonos group devices'
)
}
section('Optional Features (disable to reduce resource usage):', hideable: true) {
input 'favMatching', 'bool', title: 'Enable "Current Favorite" status.', required: false, defaultValue: true
input 'trackDataMetaData', 'bool', title: 'Include metaData and trackMetaData in trackData JSON', required: false, defaultValue: false
}
section('Update Settings:', hideable: true, hidden: true) {
paragraph "⚠ Warning: The built-in auto-update functionality should not be used if you manage this app through Hubitat Package Manager (HPM). Using both may cause conflicts, or version mismatches. If you installed via HPM, use HPM to manage updates."
input 'autoCheckUpdates', 'bool', title: 'Automatically check for updates', required: false, defaultValue: true, submitOnChange: true
if(autoCheckUpdates) {
input 'updateCheckFrequency', 'enum', title: 'Check frequency', required: false, defaultValue: 'Daily',
options: ['Daily', 'Weekly', 'Manual']
}
input 'autoInstallUpdates', 'bool', title: 'Automatically install updates', required: false, defaultValue: false, submitOnChange: true
if(autoInstallUpdates) {
input 'autoInstallTime', 'time', title: 'Install updates at this time', required: true, defaultValue: '02:00'
input 'autoInstallNextOccurrence', 'bool', title: 'Install at next occurrence of selected time (regardless of day)', required: false, defaultValue: false, submitOnChange: true
if(!autoInstallNextOccurrence) {
input 'autoInstallDayOfWeek', 'enum', title: 'Install on this day of week', required: false, defaultValue: 'Sunday',
options: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
}
}
input 'btnCheckForUpdates', 'button', title: 'Check for Updates Now', submitOnChange: false
if(state.lastUpdateCheckFormatted) {
paragraph "Last checked: ${state.lastUpdateCheckFormatted}"
}
input 'btnCheckInstalledVersions', 'button', title: 'Check Versions of Installed Files', submitOnChange: false
if(state.lastVersionCheck) {
paragraph "Last version check: ${new Date(state.lastVersionCheck).format('yyyy-MM-dd HH:mm:ss')}"
}
// Show message if update was just completed
if(state.updateJustCompleted) {
paragraph "✓ Update completed successfully!
Click 'Check Versions of Installed Files' to verify all files are at the correct version."
state.remove('updateJustCompleted')
}
// Display installed file versions
if(state.installedVersions && state.installedVersions.size() > 0) {
paragraph "Installed File Versions:"
String versionList = "
"
state.installedVersions.each { file ->
String statusColor = file.status == 'OK' ? 'green' : (file.status == 'Mismatch' ? 'red' : 'gray')
String statusIcon = file.status == 'OK' ? '✓' : (file.status == 'Mismatch' ? '✗' : '?')
versionList += "- ${statusIcon} ${file.name}: ${file.installedVersion}"
if(file.status == 'Mismatch') {
versionList += " (expected ${file.expectedVersion})"
}
versionList += "
"
}
versionList += "
"
paragraph versionList
}
if(state.versionMismatches && state.versionMismatches.size() > 0) {
paragraph "⚠ ${state.versionMismatches.size()} Version Mismatch(es) Found"
input 'btnFixVersionMismatches', 'button', title: 'Update All Mismatched Versions', submitOnChange: false
} else if(state.lastVersionCheck && state.versionMismatches != null) {
paragraph "✓ All installed files are at the correct version"
}
// Duplicate cleanup section
paragraph "
Maintenance"
input 'btnCleanupDuplicates', 'button', title: 'Check and Remove Duplicate Apps/Drivers', submitOnChange: false
if(state.duplicateCleanupResult) {
paragraph "Last cleanup result: ${state.duplicateCleanupResult}"
}
// Library management section
paragraph "
Library Management"
paragraph "Enter the library IDs from your hub's Library Code section to enable library publishing. Find these by going to Apps Code → Libraries in the Hubitat interface."
input 'libraryIdSMAPI', 'number', title: 'SMAPILibrary ID', required: false, submitOnChange: true
input 'libraryIdUtilities', 'number', title: 'UtilitiesAndLoggingLibrary ID', required: false, submitOnChange: true
if(settings.libraryIdSMAPI && settings.libraryIdUtilities) {
input 'btnPublishLibraries', 'button', title: 'Publish Libraries to Hub', submitOnChange: false
if(state.lastLibraryPublish) {
paragraph "Last publish: ${state.lastLibraryPublish}"
}
} else {
paragraph "⚠ Enter both library IDs above to enable library publishing."
}
}
section('Logging Settings:', hideable: true) {
input 'logEnable', 'bool', title: 'Enable Logging', required: false, defaultValue: true
input 'debugLogEnable', 'bool', title: 'Enable debug logging', required: false, defaultValue: false
input 'traceLogEnable', 'bool', title: 'Enable trace logging', required: false, defaultValue: false
input 'descriptionTextEnable', 'bool', title: 'Enable descriptionText logging', required: false, defaultValue: true
// input 'applySettingsButton', 'button', title: 'Apply Settings'
}
}
}
Map localPlayerPage() {
if(!state.discoveryRunning) {
subscribeToSsdpEvents(location)
ssdpDiscover()
state.discoveryRunning = true
state.discoveryEndTime = now() + 60000
app.updateSetting('playerDevices', [type: 'enum', value: getCreatedPlayerDevices()])
runIn(60, 'stopDiscovery')
runIn(1, 'updateDiscoveryTimer')
}
Integer remainingSecs = Math.max(0, (Integer)(((state.discoveryEndTime as Long) - now()) / 1000))
dynamicPage(
name: "localPlayerPage",
title: "Discovery Started!",
nextPage: 'localPlayerSelectionPage',
install: false,
uninstall: false
) {
section("Please wait while we discover your Sonos devices. Using both SSDP and mDNS discovery methods. Click Next when all your devices have been discovered.") {
if(state.discoveryRunning) {
paragraph "Discovery time remaining: ${remainingSecs} seconds"
} else {
paragraph "Discovery has stopped. Click Next to proceed or extend to continue."
}
input 'btnExtend60', 'button', title: 'Extend 60 Seconds', submitOnChange: true
input 'btnExtend300', 'button', title: 'Extend 5 Minutes', submitOnChange: true
paragraph (
"" +
"Found Devices (${discoveredSonoses.size()} primary, ${discoveredSonosSecondaries.size()} secondary): " +
"${getFoundSonoses()}" +
"
"
)
}
}
}
Map localPlayerSelectionPage() {
stopDiscovery()
// Clear feedback when entering page fresh (not from button click)
if(state.playerCreationFeedback && state.playerCreationTimestamp) {
Long timeSinceCreation = now() - (state.playerCreationTimestamp as Long)
if(timeSinceCreation > 2000) { state.remove('playerCreationFeedback') }
}
if(state.playerRemovalFeedback && state.playerRemovalTimestamp) {
Long timeSinceRemoval = now() - (state.playerRemovalTimestamp as Long)
if(timeSinceRemoval > 2000) { state.remove('playerRemovalFeedback') }
}
LinkedHashMap newlyDiscovered = discoveredSonoses.collectEntries{id, player -> [(id.toString()): player.name]}
LinkedHashMap previouslyCreated = getCurrentPlayerDevices().collectEntries{[(it.getDeviceNetworkId().toString()): it.getDataValue('name')]}
// Additional duplicate check: ensure no device appears twice with different MACs
// This can happen if a device changes IP or MAC spoofing occurs
Set seenPlayerIds = [] as Set
LinkedHashMap deduplicatedDiscovered = [:]
newlyDiscovered.each { mac, name ->
Map deviceInfo = discoveredSonoses[mac]
String playerId = deviceInfo?.id
if(playerId && !seenPlayerIds.contains(playerId)) {
deduplicatedDiscovered[mac] = name
seenPlayerIds.add(playerId)
} else if(playerId) {
logWarn("Duplicate device detected: ${name} (${mac}) has same playerId as another device. Skipping.")
} else {
deduplicatedDiscovered[mac] = name
}
}
LinkedHashMap selectionOptions = previouslyCreated
Integer newlyFoundCount = 0
deduplicatedDiscovered.each{ k,v ->
if(!selectionOptions.containsKey(k)) {
selectionOptions[k] = v
newlyFoundCount++
}
}
dynamicPage(
name: "localPlayerSelectionPage",
title: "",
nextPage: 'mainPage',
install: false,
uninstall: false
) {
section("Select your device(s) below.") {
input (
name: 'playerDevices',
title: "Available Devices (${selectionOptions.size()}): ${newlyFoundCount > 0 ? "${newlyFoundCount} newly discovered" : 'No new devices found'}",
type: 'enum',
options: selectionOptions,
multiple: true,
required: false,
submitOnChange: true
)
// Show feedback messages
if(state.playerCreationFeedback) {
paragraph "✓ ${state.playerCreationFeedback}
"
}
if(state.playerRemovalFeedback) {
paragraph "✓ ${state.playerRemovalFeedback}
"
}
// Action buttons
List willBeCreated = settings.playerDevices ? (settings.playerDevices - getCreatedPlayerDevices()) : []
List willBeRemoved = getCurrentPlayerDevices().findAll { p -> (!settings.playerDevices?.contains(p.getDeviceNetworkId())) }
if(willBeCreated.size() > 0) {
input 'createPlayerDevices', 'button', title: "Create ${willBeCreated.size()} Player(s)", submitOnChange: true, width: 6
String createList = willBeCreated.collect{ selectionOptions[it] }.join('\n')
paragraph "Will create:\n${createList}"
}
if(willBeRemoved.size() > 0) {
input 'removePlayerDevices', 'button', title: "Remove ${willBeRemoved.size()} Player(s)", submitOnChange: true, width: 6
String removeList = willBeRemoved.collect{ it.label }.join('\n')
paragraph "Will remove:\n${removeList}"
}
// Only show "all in sync" message if:
// 1. Current selection has no pending creates or removes AND
// 2. There are no newly discovered devices available to select
if(willBeCreated.size() == 0 && willBeRemoved.size() == 0 && newlyFoundCount == 0) {
paragraph "All discovered devices are already created. Select or deselect devices above to create or remove players."
} else if(willBeCreated.size() == 0 && willBeRemoved.size() == 0 && newlyFoundCount > 0) {
paragraph "Select newly discovered devices above to create them, or deselect existing devices to remove them."
}
href (
name: 'localPlayerPageRefresh',
title: 'Re-discover Devices',
description: 'Run discovery again to find new devices',
page: 'localPlayerPage'
)
}
}
}
Map groupPage() {
if(!state.userGroups) { state.userGroups = [:] }
Map coordinatorSelectionOptions = getCurrentPlayerDevices().collectEntries { player -> [(player.getDataValue('id')): player.getDataValue('name')] }
Boolean coordIsS1 = (newGroupCoordinator && getDeviceFromRincon(newGroupCoordinator)?.getDataValue('swGen') == '1')
logDebug("Selected group coordinator is S1: ${coordIsS1}")
Map playerSelectionOptionsS1 = coordinatorSelectionOptions.findAll { it.key != newGroupCoordinator && getDeviceFromRincon(it.key).getDataValue('swGen') =='1' }
Map playerSelectionOptionsS2 = coordinatorSelectionOptions.findAll { it.key != newGroupCoordinator && getDeviceFromRincon(it.key).getDataValue('swGen') =='2'}
Map playerSelectionOptions = [:]
if(!newGroupCoordinator) {playerSelectionOptions = playerSelectionOptionsS1 + playerSelectionOptionsS2}
else if(newGroupCoordinator && coordIsS1) {playerSelectionOptions = playerSelectionOptionsS1}
else {playerSelectionOptions = playerSelectionOptionsS2}
String edg = app.getSetting('editDeleteGroup')
if(edg) {
if(!app.getSetting('newGroupName')) {
app.updateSetting('newGroupName', edg)
}
if(!app.getSetting('newGroupPlayers')) {
app.updateSetting('newGroupPlayers', [type: 'enum', value: state.userGroups[edg]?.playerIds])
}
if(!app.getSetting('newGroupCoordinator')) {
app.updateSetting('newGroupCoordinator', [type: 'enum', value: state.userGroups[edg]?.groupCoordinatorId])
}
playerSelectionOptions = coordinatorSelectionOptions.findAll { it.key != newGroupCoordinator }
}
if(app.getSetting('newGroupName') == null || app.getSetting('newGroupPlayers') == null || app.getSetting('newGroupCoordinator') == null) {
state.groupPageError = 'You must select a coordinator, at least 1 follower player, and provide a name for the group'
} else {
state.remove('groupPageError')
}
dynamicPage(title: 'Sonos Player Virtual Groups', nextPage: 'mainPage') {
section {
paragraph ('This page allows you to create and delete Sonos group devices.')
}
section ('Select players to add to new group:', hideable: true){
if(state.groupPageError) {
paragraph (state.groupPageError)
}
input(name: 'newGroupName', type: 'text', title: 'Group Name:', required: false, submitOnChange: true)
input(name: 'newGroupCoordinator', type: 'enum', title: 'Select Coordinator (leader):', multiple: false, options: coordinatorSelectionOptions, required: false, submitOnChange: true, offerAll: false)
input(name: 'newGroupPlayers', type: 'enum', title: 'Select Players (followers):', multiple: true, options: playerSelectionOptions, required: false, submitOnChange: true, offerAll: false)
input(name: 'saveGroup', type: 'button', title: 'Save Group?', submitOnChange: true, width: 2, disabled: state.groupPageError != null)
input(name: 'deleteGroup', type: 'button', title: 'Delete Group?', submitOnChange: true, width: 2)
input(name: 'cancelGroupEdit', type: 'button', title: 'Cancel Edit?', submitOnChange: true, width: 2)
}
section ("Select virtual groups to edit/delete (${state.userGroups.size()} active groups found):", hideable: true) {
input (name: 'editDeleteGroup', title: '', type: 'enum', multiple: false, options: state.userGroups.keySet(), submitOnChange: true)
}
if(state.refreshGroupPage) {
section() {
state.remove('refreshGroupPage')
app.removeSetting('newGroupName')
app.removeSetting('newGroupPlayers')
app.removeSetting('newGroupCoordinator')
paragraph ""
}
}
}
}
// =============================================================================
// End App Pages
// =============================================================================
// =============================================================================
// Button Handlers
// =============================================================================
void appButtonHandler(String buttonName) {
if(buttonName == 'applySettingsButton') { applySettingsButton() }
if(buttonName == 'saveGroup') { saveGroup() }
if(buttonName == 'deleteGroup') { deleteGroup() }
if(buttonName == 'cancelGroupEdit') { cancelGroupEdit() }
if(buttonName == 'createPlayerDevices') { createPlayerDevicesWithFeedback() }
if(buttonName == 'removePlayerDevices') { removePlayerDevicesWithFeedback() }
// Update management buttons
if(buttonName == 'btnCheckForUpdates') { checkForUpdates(true) }
if(buttonName == 'btnInstallUpdate') { installUpdate() }
if(buttonName == 'btnCheckInstalledVersions') { checkInstalledVersions() }
if(buttonName == 'btnFixVersionMismatches') { fixVersionMismatches() }
if(buttonName == 'btnCleanupDuplicates') { cleanupDuplicates() }
if(buttonName == 'btnPublishLibraries') { publishLibraries() }
if(buttonName == 'btnDismissUpdate') {
state.updateAvailable = false
state.latestVersion = null
state.latestReleaseDate = null
state.latestReleaseUrl = null
}
if(buttonName == 'btnExtend60') { extendDiscovery(60) }
if(buttonName == 'btnExtend300') { extendDiscovery(300) }
}
void applySettingsButton() { configure() }
void saveGroup() {
state.userGroups[app.getSetting('newGroupName')] = [groupCoordinatorId:app.getSetting('newGroupCoordinator'), playerIds:app.getSetting('newGroupPlayers')]
app.removeSetting('newGroupName')
app.removeSetting('newGroupPlayers')
app.removeSetting('newGroupCoordinator')
app.removeSetting('editDeleteGroup')
createGroupDevices()
}
void deleteGroup() {
state.userGroups.remove(app.getSetting('newGroupName'))
app.removeSetting('newGroupName')
app.removeSetting('newGroupPlayers')
app.removeSetting('newGroupCoordinator')
state.refreshGroupPage = true
removeOrphans()
}
void cancelGroupEdit() {
app.removeSetting('newGroupName')
app.removeSetting('newGroupPlayers')
app.removeSetting('newGroupCoordinator')
app.removeSetting('editDeleteGroup')
}
// =============================================================================
// End Button Handlers
// =============================================================================
// =============================================================================
// Initialize() and Configure()
// =============================================================================
void initialize() { configure() }
void configure() {
logInfo("${app.name} updated")
unsubscribe()
scheduleUpdateCheck()
checkForUpdates()
// Initialize settings with defaults
if(settings.favMatching == null) { settings.favMatching = true }
if(settings.trackDataMetaData == null) { settings.trackDataMetaData = false }
if(settings.skipOrphanRemoval == null) { settings.skipOrphanRemoval = false }
if(settings.logEnable == null) { settings.logEnable = true }
if(settings.debugLogEnable == null) { settings.debugLogEnable = false }
if(settings.traceLogEnable == null) { settings.traceLogEnable = false }
if(settings.descriptionTextEnable == null) { settings.descriptionTextEnable = true }
try { createPlayerDevices() }
catch (Exception e) { logError("createPlayerDevices() Failed: ${e}")}
try { createGroupDevices() }
catch (Exception e) { logError("createGroupDevices() Failed: ${e}")}
state.remove('favs')
unschedule('appGetFavoritesLocal')
stopDiscovery()
}
// =============================================================================
// End Initialize() and Configure()
// =============================================================================
// =============================================================================
// Create Child Devices
// =============================================================================
void createGroupDevices() {
if(!state.userGroups) {return}
logDebug('Creating group devices...')
state.userGroups.each{ it ->
String dni = "${app.id}-SonosGroupDevice-${it.key}"
DeviceWrapper device = getChildDevice(dni)
if (device == null) {
try {
logDebug("Creating group device for ${it.key}")
device = addChildDevice('dwinks', 'Sonos Advanced Group', dni, [name: 'Sonos Group', label: "Sonos Group: ${it.key}"])
} catch (UnknownDeviceTypeException e) {logError("Sonos Advanced Group driver not found: ${e.message}")}
}
String groupCoordinatorId = it.value.groupCoordinatorId as String
String playerIds = it.value.playerIds.join(',')
ChildDeviceWrapper coordDev = app.getChildDevices().find{ cd -> cd.getDataValue('id') == groupCoordinatorId}
String householdId = coordDev.getDataValue('householdId')
device.updateDataValue('groupCoordinatorId', groupCoordinatorId)
device.updateDataValue('playerIds', playerIds)
device.updateDataValue('householdId', householdId)
}
app.removeSetting('groupDevices')
app.updateSetting('groupDevices', [type: 'enum', value: getCreatedGroupDevices()])
removeOrphans()
}
void createPlayerDevicesWithFeedback() {
List willBeCreated = settings.playerDevices ? (settings.playerDevices - getCreatedPlayerDevices()) : []
if(willBeCreated.size() == 0) {
state.playerCreationFeedback = "No new players to create"
state.playerCreationTimestamp = now()
return
}
createPlayerDevices()
// Allow time for Hubitat to register new child devices
pauseExecution(2500)
// Update the setting to reflect current state
app.updateSetting('playerDevices', [type: 'enum', value: getCreatedPlayerDevices()])
state.playerCreationFeedback = "Successfully created ${willBeCreated.size()} player(s)"
state.playerCreationTimestamp = now()
}
void removePlayerDevicesWithFeedback() {
List willBeRemoved = getCurrentPlayerDevices().findAll { p -> (!settings.playerDevices?.contains(p.getDeviceNetworkId())) }
if(willBeRemoved.size() == 0) {
state.playerRemovalFeedback = "No players to remove"
state.playerRemovalTimestamp = now()
return
}
Integer removedCount = 0
willBeRemoved.each { child ->
try {
String dni = child.getDeviceNetworkId()
logInfo("Removing player device: ${child.label}")
app.deleteChildDevice(dni)
removedCount++
} catch(Exception e) {
logError("Failed to remove device ${child.label}: ${e.message}")
}
}
// Allow time for Hubitat to unregister deleted child devices
pauseExecution(2500)
// Update the setting to reflect current state
app.updateSetting('playerDevices', [type: 'enum', value: getCreatedPlayerDevices()])
state.playerRemovalFeedback = "Successfully removed ${removedCount} player(s)"
state.playerRemovalTimestamp = now()
}
void createPlayerDevices() {
// Safety check: ensure playerDevices setting exists
if(!settings.playerDevices) {
logWarn("No player devices selected, skipping device creation")
return
}
// Safety check: ensure discovery maps are initialized
if(discoveredSonoses == null) {
logError("discoveredSonoses is null, cannot create devices")
return
}
if(discoveredSonosSecondaries == null) {
discoveredSonosSecondaries = new java.util.concurrent.ConcurrentHashMap()
logDebug("Initialized empty discoveredSonosSecondaries map")
}
settings.playerDevices.each{ dni ->
if(!dni) {
logWarn("Encountered null or empty DNI in playerDevices, skipping")
return
}
ChildDeviceWrapper cd = app.getChildDevice(dni)
Map playerInfo = discoveredSonoses[dni]
if(cd) {
String deviceName = cd.getDataValue('name')
logDebug("Not creating ${deviceName ?: dni}, child already exists.")
} else {
if(playerInfo) {
logInfo("Creating Sonos Advanced Player device for ${playerInfo?.name}")
try {
cd = addChildDevice('dwinks', 'Sonos Advanced Player', dni, [name: 'Sonos Advanced Player', label: "Sonos Advanced - ${playerInfo?.name}"])
} catch (UnknownDeviceTypeException e) {
logError("Sonos Advanced Player driver not found: ${e.message}")
} catch (Exception e) {
logError("Failed to create device for ${dni}: ${e.message}")
}
} else {
logWarn("Attempted to create child device for ${dni} but did not find playerInfo")
}
}
// Only update device info if we have both a valid device and player info
if(cd && playerInfo) {
try {
logInfo("Updating player info with latest info from discovery...")
// First, update all basic player info
playerInfo.each { key, value ->
if(key != null && value != null) {
cd.updateDataValue(key as String, value as String)
}
}
// Then handle secondaries
LinkedHashMap macToRincon = discoveredSonoses.collectEntries{ k,v ->
if(k != null && v != null && v.id != null) {
return [(k as String): (v.id as String)]
}
return [:]
}
String rincon = macToRincon[dni]
if(rincon) {
LinkedHashMap secondaries = discoveredSonosSecondaries.findAll{k,v ->
v != null && v.primaryDeviceId != null && v.primaryDeviceId == rincon
}
if(secondaries){
List secondaryDeviceIps = secondaries.collect{it.value?.deviceIp}.findAll{it != null}
List secondaryIds = secondaries.collect{it.value?.id}.findAll{it != null}
if(secondaryDeviceIps && secondaryIds) {
cd.updateDataValue('secondaryDeviceIps', secondaryDeviceIps.join(','))
cd.updateDataValue('secondaryIds', secondaryIds.join(','))
}
}
}
// Validate critical device data fields are populated
List criticalFields = ['id', 'deviceIp', 'localUpnpHost', 'localUpnpUrl', 'websocketUrl']
List missingFields = []
criticalFields.each { field ->
String value = cd.getDataValue(field)
if(!value || value == 'null') {
missingFields << field
}
}
if(missingFields.size() > 0) {
logWarn("Device ${cd.label} is missing critical data fields: ${missingFields.join(', ')}. This may cause issues.")
logWarn("Player info available: ${playerInfo.keySet().join(', ')}")
// Schedule a retry of secondaryConfiguration in case data gets populated later
runIn(10, 'retryDeviceConfiguration', [data: [dni: dni]])
}
// Small delay to ensure all data values are committed before secondaryConfiguration
pauseExecution(500)
cd.secondaryConfiguration()
} catch (Exception e) {
logError("Failed to configure device ${dni}: ${e.message}")
}
} else if(!cd) {
logWarn("Skipping device configuration for ${dni} - device not created")
} else if(!playerInfo) {
logWarn("Skipping device configuration for ${dni} - no player info available")
}
}
// Note: removeOrphans() is now handled by the explicit Remove Players button
}
void retryDeviceConfiguration(Map data) {
String dni = data?.dni
if(!dni) {
logWarn('retryDeviceConfiguration called without DNI')
return
}
ChildDeviceWrapper cd = app.getChildDevice(dni)
if(!cd) {
logWarn("Cannot retry configuration for ${dni} - device not found")
return
}
Map playerInfo = discoveredSonoses[dni]
if(!playerInfo) {
logWarn("Cannot retry configuration for ${dni} - no player info available")
return
}
logInfo("Retrying device configuration for ${cd.label}...")
// Re-apply all device data
playerInfo.each { key, value ->
if(key != null && value != null) {
String currentValue = cd.getDataValue(key as String)
if(!currentValue || currentValue == 'null') {
logDebug("Setting missing field ${key} = ${value}")
cd.updateDataValue(key as String, value as String)
}
}
}
// Validate again
List criticalFields = ['id', 'deviceIp', 'localUpnpHost', 'localUpnpUrl', 'websocketUrl']
List stillMissing = []
criticalFields.each { field ->
String value = cd.getDataValue(field)
if(!value || value == 'null') {
stillMissing << field
}
}
if(stillMissing.size() > 0) {
logError("Device ${cd.label} still missing critical fields after retry: ${stillMissing.join(', ')}")
logError("This device may not function correctly. Try deleting and re-discovering it.")
} else {
logInfo("Device ${cd.label} configuration completed successfully on retry")
pauseExecution(500)
cd.secondaryConfiguration()
}
}
void removeOrphans() {
getCurrentGroupDevices().each{ child ->
String dni = child.getDeviceNetworkId()
if(dni in settings.groupDevices) { return }
else {
logInfo("Removing group device not found in selected devices list: ${child}")
app.deleteChildDevice(dni)
}
}
if(!settings.skipOrphanRemoval) {
getCurrentPlayerDevices().each{ child ->
String dni = child.getDeviceNetworkId()
if(dni in settings.playerDevices) { return }
else {
logInfo("Removing player device not found in selected devices list: ${child}")
app.deleteChildDevice(dni)
}
}
}
}
// =============================================================================
// End Create Child Devices
// =============================================================================
// =============================================================================
// Local Discovery
//
// This section implements dual discovery methods for Sonos devices:
// 1. SSDP (Simple Service Discovery Protocol) - Legacy method, works on most networks
// 2. mDNS (Multicast DNS) - Modern method added in Hubitat 2.4.1+, more reliable
//
// Duplicate Prevention:
// - Devices are keyed by MAC address (primary identifier)
// - Additional checks prevent duplicates by playerId and IP address
// - Selection page deduplicates by playerId to handle edge cases
// =============================================================================
void stopDiscovery() {
logInfo('Stopping discovery...')
state.remove('discoveryRunning')
state.remove('discoveryEndTime')
unsubscribe(location, 'ssdpTerm.upnp:rootdevice')
unsubscribe(location, 'sdpTerm.ssdp:all')
unschedule('sendFoundSonosEvents')
unschedule('processMdnsDiscovery')
unschedule('stopDiscovery')
unschedule('updateDiscoveryTimer')
try {
if(this.respondsTo('unregisterMDNSListener')) {
unregisterMDNSListener('_sonos._tcp')
unregisterMDNSListener('_http._tcp')
}
} catch(Exception e) { logTrace("Could not unregister mDNS listeners: ${e.message}") }
}
void updateDiscoveryTimer() {
if(!state.discoveryRunning || !state.discoveryEndTime) {
logDebug("updateDiscoveryTimer: stopping - discoveryRunning=${state.discoveryRunning}, discoveryEndTime=${state.discoveryEndTime}")
return
}
Integer remainingSecs = Math.max(0, (Integer)(((state.discoveryEndTime as Long) - now()) / 1000))
logTrace("updateDiscoveryTimer: sending event with ${remainingSecs} seconds remaining")
app.sendEvent(name: 'discoveryTimer', value: "Discovery time remaining: ${remainingSecs} seconds")
if(remainingSecs > 0) {
runIn(1, 'updateDiscoveryTimer')
} else {
logDebug("updateDiscoveryTimer: timer reached 0, stopping updates")
}
}
void extendDiscovery(Integer seconds) {
logInfo("Extending discovery by ${seconds} seconds")
if(!state.discoveryRunning) {
// Restart discovery without clearing already-discovered devices
subscribeToSsdpEvents(location)
sendHubCommand(new hubitat.device.HubAction("lan discovery upnp:rootdevice", hubitat.device.Protocol.LAN))
sendHubCommand(new hubitat.device.HubAction("lan discovery ssdp:all", hubitat.device.Protocol.LAN))
startMdnsDiscovery()
state.discoveryRunning = true
runIn(1, 'updateDiscoveryTimer')
}
// Add seconds to current remaining time (or from now if already expired)
Long currentEnd = state.discoveryEndTime ? (state.discoveryEndTime as Long) : now()
Long newEnd = Math.max(currentEnd, now()) + (seconds * 1000L)
state.discoveryEndTime = newEnd
Integer totalRemaining = (Integer)(((newEnd) - now()) / 1000)
unschedule('stopDiscovery')
runIn(totalRemaining, 'stopDiscovery')
}
void ssdpDiscover() {
logDebug("Starting SSDP Discovery...")
discoveredSonoses = new java.util.concurrent.ConcurrentHashMap()
discoveredSonosSecondaries = new java.util.concurrent.ConcurrentHashMap()
discoveryQueue = new ConcurrentLinkedQueue()
sendHubCommand(new hubitat.device.HubAction("lan discovery upnp:rootdevice", hubitat.device.Protocol.LAN))
sendHubCommand(new hubitat.device.HubAction("lan discovery ssdp:all", hubitat.device.Protocol.LAN))
// Also start mDNS discovery
startMdnsDiscovery()
}
void startMdnsDiscovery() {
try {
// Check if mDNS methods exist (requires Hubitat 2.4.1+)
if(!this.respondsTo('registerMDNSListener')) {
logDebug("mDNS not available on this hub firmware version")
return
}
// Register mDNS listener for Sonos devices
// Sonos uses _sonos._tcp for its mDNS service
registerMDNSListener('_sonos._tcp')
logDebug("Registered mDNS listener for Sonos devices (_sonos._tcp)")
// Also listen for general device services that Sonos might broadcast
registerMDNSListener('_http._tcp')
logDebug("Registered mDNS listener for HTTP services (_http._tcp)")
// Schedule processing of mDNS entries after a delay to allow discovery
runIn(5, 'processMdnsDiscovery')
} catch(Exception e) {
logWarn("mDNS discovery not available or failed: ${e.message}")
}
}
void processMdnsDiscovery() {
try {
logDebug("Processing mDNS discovered devices...")
// Get all mDNS entries
def mdnsEntries = getMDNSEntries()
if(!mdnsEntries) {
logDebug("No mDNS entries found")
return
}
logDebug("Found ${mdnsEntries.size()} mDNS entries")
mdnsEntries.each { entry ->
// Filter for Sonos devices - they typically have 'Sonos' in the name or specific service types
String serviceName = entry.name ?: ''
String serviceType = entry.type ?: ''
String ipAddress = entry.ipAddress ?: ''
Integer port = entry.port ?: 0
logTrace("mDNS entry: name=${serviceName}, type=${serviceType}, ip=${ipAddress}, port=${port}")
// Check if this looks like a Sonos device
if(serviceName.toLowerCase().contains('sonos') || serviceType == '_sonos._tcp') {
logDebug("Found potential Sonos device via mDNS: ${serviceName} at ${ipAddress}")
processDiscoveredSonosDevice(ipAddress)
} else if(ipAddress && port == 1400) {
// Port 1400 is Sonos UPnP port - worth checking
logTrace("Found device on Sonos UPnP port 1400: ${ipAddress}")
processDiscoveredSonosDevice(ipAddress)
}
}
runIn(2, 'sendFoundSonosEvents')
} catch(Exception e) {
logWarn("Error processing mDNS discovery: ${e.message}")
}
}
void processDiscoveredSonosDevice(String ipAddress) {
if(!ipAddress) { return }
// Check if we already discovered this IP (in primaries or secondaries)
boolean alreadyDiscovered = discoveredSonoses.values().any { it.deviceIp == ipAddress }
boolean alreadyDiscoveredSecondary = discoveredSonosSecondaries.values().any { it.deviceIp == ipAddress }
if(alreadyDiscovered || alreadyDiscoveredSecondary) {
logTrace("Device at ${ipAddress} already discovered, skipping")
return
}
// Try to get player info
LinkedHashMap playerInfo = getPlayerInfoLocalSync("${ipAddress}:1443")
if(!playerInfo) {
logTrace("Could not get player info for ${ipAddress}")
return
}
GPathResult deviceDescription = getDeviceDescriptionLocalSync("${ipAddress}")
if(!deviceDescription) {
logTrace("Could not get device description for ${ipAddress}")
return
}
// Process using the same logic as SSDP
processSonosDeviceInfo(playerInfo, deviceDescription, ipAddress)
}
@CompileStatic
void subscribeToSsdpEvents(Location location) {
logDebug("Subscribing to SSDP Discovery events...")
subscribe(location, 'ssdpTerm.upnp:rootdevice', 'ssdpEventHandler')
subscribe(location, 'sdpTerm.ssdp:all', 'ssdpEventHandler')
}
void ssdpEventHandler(Event event) {
if(!state.discoveryRunning) { return }
LinkedHashMap parsedEvent = parseLanMessage(event?.description)
processParsedSsdpEvent(parsedEvent)
}
@CompileStatic
void processParsedSsdpEvent(LinkedHashMap event) {
String ipAddress = convertHexToIP(event?.networkAddress as String)
String ipPort = convertHexToInt(event?.deviceAddress as String).toString()
LinkedHashMap playerInfo = getPlayerInfoLocalSync("${ipAddress}:1443")
if(playerInfo) {
logTrace("Discovered playerInfo for ${ipAddress}")
} else {
logTrace("Did not receive playerInfo for ${ipAddress}")
return
}
GPathResult deviceDescription = getDeviceDescriptionLocalSync("${ipAddress}")
if(deviceDescription) {
logTrace("Discovered device description for ${ipAddress}")
} else {
logTrace("Did not receive device description for ${ipAddress}")
return
}
processSonosDeviceInfo(playerInfo, deviceDescription, ipAddress)
}
@CompileStatic
void processSonosDeviceInfo(LinkedHashMap playerInfo, GPathResult deviceDescription, String ipAddress) {
LinkedHashMap playerInfoDevice = playerInfo?.device as LinkedHashMap
String modelName = deviceDescription['device']['modelName']
String mac = (deviceDescription['device']['MACAddress']).toString().replace(':','')
String playerName = playerInfoDevice?.name
String playerDni = mac
String swGen = playerInfoDevice?.swGen
String websocketUrl = playerInfoDevice?.websocketUrl
String householdId = playerInfo?.householdId
String playerId = playerInfo?.playerId
String groupId = playerInfo?.groupId
List deviceCapabilities = playerInfoDevice?.capabilities as List
if(mac && playerInfoDevice?.name) {
logTrace("Processing Sonos device: MAC=${mac}, name=${playerInfoDevice?.name}, IP=${ipAddress}")
}
// Use MAC as primary key, but also check for duplicate IPs/playerIds to prevent same device listed multiple times
// Check against both primary and secondary maps
boolean isDuplicate = false
discoveredSonoses.each { key, value ->
LinkedHashMap existingDevice = value as LinkedHashMap
if(key == mac || existingDevice.id == playerId || existingDevice.deviceIp == ipAddress) {
logTrace("Device already discovered in primaries - MAC: ${mac}, playerId: ${playerId}, IP: ${ipAddress}")
isDuplicate = true
// Update the existing entry with latest info to keep it fresh
if(key == mac) {
existingDevice.deviceIp = ipAddress
existingDevice.localApiUrl = "https://${ipAddress}:1443/api/v1/"
existingDevice.localUpnpUrl = "http://${ipAddress}:1400"
existingDevice.localUpnpHost = "${ipAddress}:1400"
}
}
}
if(!isDuplicate) {
discoveredSonosSecondaries.each { key, value ->
LinkedHashMap existingDevice = value as LinkedHashMap
if(key == mac || existingDevice.id == playerId || existingDevice.deviceIp == ipAddress) {
logTrace("Device already discovered in secondaries - MAC: ${mac}, playerId: ${playerId}, IP: ${ipAddress}")
isDuplicate = true
}
}
}
if(isDuplicate) { return }
LinkedHashMap discoveredSonos = [
name: playerName,
id: playerId,
swGen: swGen,
capabilities: deviceCapabilities,
modelName: modelName,
householdId: householdId,
websocketUrl: websocketUrl,
deviceIp: "${ipAddress}",
localApiUrl: "https://${ipAddress}:1443/api/v1/",
localUpnpUrl: "http://${ipAddress}:1400",
localUpnpHost: "${ipAddress}:1400"
]
// Handle secondary/satellite devices (subs, surrounds, right channel in stereo pair)
if(playerInfoDevice?.primaryDeviceId) {
LinkedHashMap discoveredSonosSecondary = [
primaryDeviceId: playerInfoDevice?.primaryDeviceId,
id: playerId,
swGen: swGen,
capabilities: deviceCapabilities,
modelName: modelName,
householdId: householdId,
websocketUrl: websocketUrl,
deviceIp: "${ipAddress}",
localApiUrl: "https://${ipAddress}:1443/api/v1/",
localUpnpUrl: "http://${ipAddress}:1400",
localUpnpHost: "${ipAddress}:1400"
]
// Check for duplicate secondaries
boolean secondaryExists = discoveredSonosSecondaries.values().any { secondaryDevice ->
LinkedHashMap secondary = secondaryDevice as LinkedHashMap
return secondary.id == playerId
}
if(!secondaryExists) {
discoveredSonosSecondaries[mac] = discoveredSonosSecondary
logTrace("Found secondary ${modelName} (${playerId}) for primary ${playerInfoDevice?.primaryDeviceId}")
}
// Secondary devices should never appear in the main discovered list
scheduleMethod(2, 'sendFoundSonosEvents')
return
}
// Only primary (standalone) devices reach here
if(discoveredSonos?.name != null && discoveredSonos?.name != 'null') {
discoveredSonoses[mac] = discoveredSonos
logInfo("Discovered Sonos device: ${playerName} (${modelName}) at ${ipAddress}")
scheduleMethod(2, 'sendFoundSonosEvents')
} else {
logTrace("Device id:${discoveredSonos?.id} responded to discovery, but did not provide device name. This is expected for right channel speakers on stereo pairs, subwoofers, and other 'non primary' devices.")
}
}
@CompileStatic
String getFoundSonoses() {
String foundDevices = ''
List discoveredSonosesNames = discoveredSonoses.collect{Object k, Object v -> ((LinkedHashMap)v)?.name as String }
discoveredSonosSecondaries.each{ Object k, Object v ->
LinkedHashMap secondary = (LinkedHashMap)v
String primaryDeviceId = secondary?.primaryDeviceId as String
String modelName = secondary?.modelDisplayName ?: secondary?.modelName ?: 'Secondary'
if(primaryDeviceId) {
String primaryDNI = getDNIFromRincon(primaryDeviceId)
if(discoveredSonoses.containsKey(primaryDNI)) {
LinkedHashMap primary = (LinkedHashMap)discoveredSonoses[primaryDNI]
discoveredSonosesNames.add("${primary?.name as String} (${modelName})".toString())
}
}
}
discoveredSonosesNames.sort()
discoveredSonosesNames.each{ discoveredSonos -> foundDevices += "\n${discoveredSonos}" }
return foundDevices
}
void sendFoundSonosEvents() {
app.sendEvent(name: 'sonosDiscoveredCount', value: "Found Devices (${discoveredSonoses.size()} primary, ${discoveredSonosSecondaries.size()} secondary): ")
app.sendEvent(name: 'sonosDiscovered', value: getFoundSonoses())
}
// =============================================================================
// End Local Discovery
// =============================================================================
// =============================================================================
// Helper methods
// =============================================================================
/**
* Wrapper for runIn() to allow calls from @CompileStatic methods.
* The dynamic method lookup for runIn() is incompatible with static compilation.
*/
void scheduleMethod(Integer seconds, String methodName) {
runIn(seconds, methodName)
}
@CompileStatic
String getDNIFromRincon(String rincon) {
return rincon.tokenize('_')[1][0..-6]
}
ChildDeviceWrapper getDeviceFromRincon(String rincon) {
List childDevices = app.getCurrentPlayerDevices()
ChildDeviceWrapper dev = childDevices.find{ it.getDataValue('id') == rincon}
return dev
}
List getDevicesFromRincons(LinkedHashSet rincons) {
List children = rincons.collect{ player -> getDeviceFromRincon(player) }
children.removeAll([null])
return children
}
List getDevicesFromRincons(List rincons) {
List children = rincons.collect{ player -> getDeviceFromRincon(player) }
children.removeAll([null])
return children
}
List getCreatedPlayerDevices() {
List childDevices = app.getCurrentPlayerDevices()
List pds = []
childDevices.each() {cd -> pds.add("${cd.getDeviceNetworkId()}")}
return pds
}
List getCreatedGroupDevices() {
List childDevices = app.getCurrentGroupDevices()
List pds = []
childDevices.each() {cd -> pds.add("${cd.getDeviceNetworkId()}")}
return pds
}
List getUserGroupsDNIsFromUserGroups() {
List dnis = state.userGroups.collect{ k,v -> "${app.id}-SonosGroupDevice-${k}" }
return dnis
}
List getCurrentPlayerDevices() {
List currentPlayers = []
app.getChildDevices().each{child -> if(child.getDataValue('id')) { currentPlayers.add(child)}}
return currentPlayers
}
// @CompileStatic
// void registerAllPlayersInRinconMap(DeviceWrapper cd) {
// cd.addAllPlayersToRinconMap(getCurrentPlayerDevices())
// }
List getCurrentGroupDevices() {
List currentGroupDevs = []
app.getChildDevices().each{child -> if(child.getDataValue('id') == null) { currentGroupDevs.add(child)}}
return currentGroupDevs
}
List getAllPlayersForGroupDevice(DeviceWrapper device) {
List playerIds = [device.getDataValue('groupCoordinatorId')]
playerIds.addAll(device.getDataValue('playerIds').split(','))
return playerIds
}
LinkedHashMap getPlayerInfoLocalSync(String ipAddress) {
ipAddress = ipAddress.contains(':') ? ipAddress : "${ipAddress}:1443"
LinkedHashMap params = [
uri: "${getLocalApiPrefix(ipAddress)}/players/local/info",
headers: ['X-Sonos-Api-Key': '123e4567-e89b-12d3-a456-426655440000'],
requestContentType: 'application/json',
contentType: 'application/json',
ignoreSSLIssues: true
]
try {
httpGet(params) { resp ->
if (resp && resp.data && resp.success) { return resp.data }
}
} catch(Exception e){
logInfo("Could not connect to: ${ipAddress}. If this is a Sonos player, please report an issue. Note that RIGHT channel speakers on a stereo pair, subwoofers, or rear channel speakers this is expected. Only LEFT channel in stereo pairs (or Arc/Beam in a center + rear setup) will respond.")
}
}
GPathResult getDeviceDescriptionLocalSync(String ipAddress) {
if(!ipAddress.contains(':')) { ipAddress = "${ipAddress}:1400"}
Map params = [
uri: "http://${ipAddress}/xml/device_description.xml",,
requestContentType: 'application/xml',
contentType: 'application/xml'
]
try {
httpGet(params) { resp ->
if (resp && resp.data && resp.success) { return resp.data }
else { logError(resp.data) }
}
} catch(Exception e){
logInfo("Could not connect to: ${ipAddress}. If this is a Sonos player, please report an issue. Note that RIGHT channel speakers on a stereo pair, subwoofers, or rear channel speakers this is expected. Only LEFT channel in stereo pairs (or Arc/Beam in a center + rear setup) will respond.")
}
}
void getPlayerInfoLocalCallback(AsyncResponse response, Map data) {
if(!responseIsValid(response, 'getPlayerInfoLocalCallback')) { return }
Map respJson = response.getJson()
String playerName = respJson?.device?.name
String playerDni = (respJson?.device?.serialNumber.replace('-','').tokenize(':'))[0]
String swGen = respJson?.device?.swGen
String websocketUrl = respJson?.device?.websocketUrl
String householdId = respJson?.householdId
String playerId = respJson?.playerId
String groupId = respJson?.groupId
}
void getZoneGroupAttributesAsync(DeviceWrapper device, String callbackMethod = 'getZoneGroupAttributesAsyncCallback', Map data = null) {
String ip = device.getDataValue('localUpnpHost')
Map params = getSoapActionParams(ip, ZoneGroupTopology, 'GetZoneGroupAttributes')
asynchttpPost(callbackMethod, params, data)
}
void getZoneGroupAttributesAsyncCallback(AsyncResponse response, Map data) {
if(!responseIsValid(response, 'getZoneGroupAttributesAsyncCallback')) { return }
}
List getCurrentGroupedDevices(DeviceWrapper device) {
String ip = device.getDataValue('localUpnpHost')
List groupedRincons
Map params = getSoapActionParams(ip, ZoneGroupTopology, 'GetZoneGroupAttributes')
httpPost(params) { resp ->
if (resp && resp.data && resp.success) {
GPathResult xml = resp.data
groupedRincons = (xml['Body']['GetZoneGroupAttributesResponse']['CurrentZonePlayerUUIDsInGroup'].text()).toString().tokenize(',')
}
else { logError(resp.data) }
}
List groupedDevices = getDevicesFromRincons(groupedRincons)
return groupedDevices
}
List getGroupedPlayerDevicesFromGetZoneGroupAttributes(GPathResult xml, String rincon) {
List groupedDevices = []
List groupIds = []
List groupedRincons = xml['Body']['GetZoneGroupAttributesResponse']['CurrentZonePlayerUUIDsInGroup'].text().tokenize(',')
if(groupedRincons.size() == 0) {
logDebug("No grouped rincons found!")
return
}
groupedRincons.each{groupIds.add("${it}".tokenize('_')[1][0..-6])}
groupIds.each{groupedDevices.add(getChildDevice(it))}
return groupedDevices
}
String getGroupForPlayerDeviceLocal(DeviceWrapper device) {
String ip = device.getDataValue('localUpnpHost')
String groupId
Map params = getSoapActionParams(ip, ZoneGroupTopology, 'GetZoneGroupAttributes')
httpPost(params) { resp ->
if (resp && resp.data && resp.success) {
GPathResult xml = resp.data
groupId = xml['Body']['GetZoneGroupAttributesResponse']['CurrentZoneGroupID'].text().toString()
}
else { logError(resp.data) }
}
return groupId
}
DeviceWrapper getGroupCoordinatorForPlayerDeviceLocal(DeviceWrapper device) {
String ip = device.getDataValue('localUpnpHost')
String groupId
Map params = getSoapActionParams(ip, ZoneGroupTopology, 'GetZoneGroupAttributes')
httpPost(params) { resp ->
if (resp && resp.data && resp.success) {
GPathResult xml = resp.data
groupId = xml['Body']['GetZoneGroupAttributesResponse']['CurrentZoneGroupID'].text().toString()
}
else { logError(resp.data) }
}
return getDeviceFromRincon(groupId.tokenize(':')[0])
}
String getHouseholdForPlayerDeviceLocal(DeviceWrapper device) {
String ip = device.getDataValue('localUpnpHost')
String groupId
Map params = getSoapActionParams(ip, ZoneGroupTopology, 'GetZoneGroupAttributes')
httpPost(params) { resp ->
if (resp && resp.data && resp.success) {
GPathResult xml = resp.data
groupId = xml['Body']['GetZoneGroupAttributesResponse']['CurrentMuseHouseholdId'].text().toString()
}
else { logError(resp.data) }
}
return groupId
}
Boolean hasLeftAndRightChannelsSync(DeviceWrapper device) {
String householdId = device.getDataValue('householdId')
String localApiUrl = device.getDataValue('localApiUrl')
String endpoint = "households/${householdId}/groups"
String uri = "${localApiUrl}${endpoint}"
Map params = [uri: uri]
Map json = sendLocalJsonQuerySync([params:params])
Map playerInfo = json?.players.find{it?.id == device.getDataValue('id')}
Boolean hasLeftChannel = playerInfo?.zoneInfo?.members.find{it?.channelMap.contains('LF')}
Boolean hasRightChannel = playerInfo?.zoneInfo?.members.find{it?.channelMap.contains('RF')}
return hasLeftChannel && hasRightChannel
}
String unEscapeOnce(String text) {
return text.replace('<','<').replace('>','>').replace('"','"')
}
String unEscapeMetaData(String text) {
return text.replace('<','<').replace('>','>').replace('"','"')
}
// =============================================================================
// Helper methods
// =============================================================================
// =============================================================================
// Component Methods for Child Event Processing
// =============================================================================
@CompileStatic
void updateGroupDevices(String coordinatorId, List playersInGroup) {
logTrace('updateGroupDevices')
// Update group device with current on/off state and currently joined players
List groupsForCoord = getCurrentGroupDevices().findAll{it.getDataValue('groupCoordinatorId') == coordinatorId }
// Resolve RINCON IDs to friendly player names for the currentlyJoinedPlayers attribute
List joinedPlayerNames = []
playersInGroup.each { String rincon ->
ChildDeviceWrapper playerDev = getDeviceFromRincon(rincon)
if(playerDev != null) {
String name = playerDev.getDataValue('name')
if(name != null && name != '') { joinedPlayerNames.add(name) }
}
}
String joinedPlayersValue = joinedPlayerNames.join(', ')
groupsForCoord.each{gd ->
List playerIds = gd.getDataValue('playerIds').tokenize(',')
HashSet list1 = new HashSet(playerIds)
HashSet list2 = new HashSet(playersInGroup)
list1.add(coordinatorId)
list2.add(coordinatorId)
Boolean allPlayersAreGrouped = list1.equals(list2)
if(allPlayersAreGrouped) { gd.sendEvent(name: 'switch', value: 'on') }
else { gd.sendEvent(name: 'switch', value: 'off') }
gd.sendEvent(name: 'currentlyJoinedPlayers', value: joinedPlayersValue)
}
}
/**
* Forward group volume, mute state, and switch state to all group devices that use this coordinator
* Called by the coordinator player when groupVolume, groupMute, or grouping state changes
* @param coordinatorId The RINCON ID of the coordinator
* @param groupVolume The current group volume (0-100)
* @param groupMute The current group mute state ('muted' or 'unmuted')
* @param isGroupedWithFollowers Whether speakers are actually grouped (coordinator has followers)
*/
void updateGroupDeviceVolumeState(String coordinatorId, Integer groupVolume, String groupMute, Boolean isGroupedWithFollowers = null) {
if(!coordinatorId) { return }
List groupsForCoord = getCurrentGroupDevices().findAll {
it.getDataValue('groupCoordinatorId') == coordinatorId
}
groupsForCoord.each { gd ->
if(groupVolume != null) {
gd.sendEvent(name: 'volume', value: groupVolume, unit: '%')
}
if(groupMute != null) {
gd.sendEvent(name: 'mute', value: groupMute)
}
if(isGroupedWithFollowers != null) {
// Update switch state: 'on' when grouped with followers, 'off' when standalone
gd.sendEvent(name: 'switch', value: isGroupedWithFollowers ? 'on' : 'off')
}
}
}
/**
* Forward MusicPlayer attributes to all group devices that use this coordinator
* Called by the coordinator player when playback status or track info changes
* @param coordinatorId The RINCON ID of the coordinator
* @param status The current playback status ('playing', 'paused', 'stopped')
* @param trackData The current track data JSON string
* @param trackDescription The current track description string
*/
void updateGroupDeviceMusicPlayerState(String coordinatorId, String status, String trackData, String trackDescription) {
if(!coordinatorId) { return }
List groupsForCoord = getCurrentGroupDevices().findAll {
it.getDataValue('groupCoordinatorId') == coordinatorId
}
groupsForCoord.each { gd ->
if(status != null) {
gd.sendEvent(name: 'status', value: status)
}
if(trackData != null) {
gd.sendEvent(name: 'trackData', value: trackData)
}
if(trackDescription != null) {
gd.sendEvent(name: 'trackDescription', value: trackDescription)
}
}
}
/**
* Forward extended playback attributes to all group devices that use this coordinator
* Called by the coordinator player when playback metadata changes
* @param coordinatorId The RINCON ID of the coordinator
* @param attributes Map of attribute names to values to forward
*/
void updateGroupDeviceExtendedPlaybackState(String coordinatorId, Map attributes) {
if(!coordinatorId || !attributes) { return }
List groupsForCoord = getCurrentGroupDevices().findAll {
it.getDataValue('groupCoordinatorId') == coordinatorId
}
groupsForCoord.each { gd ->
attributes.each { attrName, attrValue ->
if(attrValue != null) {
gd.sendEvent(name: attrName, value: attrValue)
}
}
}
}
// =============================================================================
// Version Checking for Installed Files
// =============================================================================
void checkInstalledVersions() {
logInfo('Checking versions of all installed files...')
state.lastVersionCheck = now()
state.versionMismatches = []
state.installedVersions = []
// Get the ACTUAL version from the hub's app code, not the running instance
// This is critical after updates when the instance may have the old version
String appVersion = getActualInstalledAppVersion()
logDebug("Current app version from hub: ${appVersion}")
// Get package manifest for the CURRENT installed version (not main branch)
// This ensures we're comparing against the correct expected versions
String manifestUrl = "https://raw.githubusercontent.com/DanielWinks/Hubitat-Public/v${appVersion}/PackageManifests/SonosAdvancedController/packageManifest.json"
Map manifest = null
try {
Map params = [
uri: manifestUrl,
contentType: 'application/json',
timeout: 15
]
httpGet(params) { resp ->
if(resp?.status == 200 && resp.data) {
manifest = resp.data
}
}
} catch(Exception e) {
logWarn("Failed to retrieve package manifest for v${appVersion}: ${e.message}")
logDebug("Trying main branch manifest as fallback...")
// Fallback to main branch if version-specific manifest not found
try {
manifestUrl = 'https://raw.githubusercontent.com/DanielWinks/Hubitat-Public/main/PackageManifests/SonosAdvancedController/packageManifest.json'
Map fallbackParams = [
uri: manifestUrl,
contentType: 'application/json',
timeout: 15
]
httpGet(fallbackParams) { resp ->
if(resp?.status == 200 && resp.data) {
manifest = resp.data
}
}
} catch(Exception e2) {
logWarn("Failed to retrieve fallback manifest: ${e2.message}")
}
}
if(!manifest) {
logWarn('Failed to retrieve package manifest for version checking')
return
}
logDebug("Using manifest version: ${manifest.version}")
// Check each driver in the manifest
manifest.drivers?.each { driver ->
String driverName = driver.name
String namespace = driver.namespace ?: 'dwinks'
String expectedVersion = driver.version ?: appVersion
logDebug("Checking ${driverName} (${namespace})...")
String installedVersion = getInstalledDriverVersion(driverName, namespace)
if(installedVersion && installedVersion != expectedVersion) {
logWarn("Version mismatch: ${driverName} - installed: ${installedVersion}, expected: ${expectedVersion}")
state.versionMismatches << [
name: driverName,
namespace: namespace,
installedVersion: installedVersion,
expectedVersion: expectedVersion,
location: driver.location,
type: 'driver'
]
state.installedVersions << [
name: driverName,
installedVersion: installedVersion,
expectedVersion: expectedVersion,
status: 'Mismatch'
]
} else if(installedVersion) {
logDebug("${driverName} version OK: ${installedVersion}")
state.installedVersions << [
name: driverName,
installedVersion: installedVersion,
expectedVersion: expectedVersion,
status: 'OK'
]
} else {
logDebug("${driverName} not found or could not determine version")
state.installedVersions << [
name: driverName,
installedVersion: 'Not Installed',
expectedVersion: expectedVersion,
status: 'Not Found'
]
}
}
// Check library files
// Libraries can be checked if library IDs are configured
logInfo("Library checking: libraryIdSMAPI=${libraryIdSMAPI}, libraryIdUtilities=${libraryIdUtilities}, manifest.files count=${manifest.files?.size() ?: 0}")
logInfo("Settings check: libraryIdSMAPI=${settings.libraryIdSMAPI}, libraryIdUtilities=${settings.libraryIdUtilities}")
if(settings.libraryIdSMAPI && settings.libraryIdUtilities) {
logInfo("Checking library versions using configured IDs (SMAPI: ${settings.libraryIdSMAPI}, Utilities: ${settings.libraryIdUtilities})...")
if(!manifest.files || manifest.files.size() == 0) {
logWarn("No library files found in manifest!")
} else {
logInfo("Found ${manifest.files.size()} file(s) in manifest to check")
}
manifest.files?.each { file ->
String fileName = file.name
String expectedVersion = appVersion
logInfo("Processing file from manifest: ${fileName}")
// Determine which library ID to use based on filename
Integer libraryId = null
if(fileName.contains('SMAPILibrary')) {
libraryId = settings.libraryIdSMAPI as Integer
} else if(fileName.contains('UtilitiesAndLoggingLibrary')) {
libraryId = settings.libraryIdUtilities as Integer
}
if(libraryId) {
logInfo("Checking library ${fileName} (ID: ${libraryId})...")
// Determine expected library name and namespace
String expectedName = ''
String expectedNamespace = 'dwinks'
if(fileName.contains('SMAPILibrary')) {
expectedName = 'SMAPILibrary'
} else if(fileName.contains('UtilitiesAndLoggingLibrary')) {
expectedName = 'UtilitiesAndLoggingLibrary'
}
Map libraryResult = getInstalledLibraryVersionWithValidation(libraryId, expectedName, expectedNamespace)
String installedVersion = libraryResult.version
String error = libraryResult.error
logInfo("Library ${fileName} installed version: ${installedVersion ?: 'Not found'}, expected: ${expectedVersion}")
if(error) {
// Library ID is wrong or other error occurred
logWarn("Library check failed: ${error}")
state.installedVersions << [
name: fileName,
installedVersion: 'Error',
expectedVersion: expectedVersion,
status: error
]
} else if(installedVersion && installedVersion != expectedVersion) {
logWarn("Version mismatch: ${fileName} - installed: ${installedVersion}, expected: ${expectedVersion}")
state.versionMismatches << [
name: expectedName,
fileName: fileName,
namespace: expectedNamespace,
installedVersion: installedVersion,
expectedVersion: expectedVersion,
libraryId: libraryId,
type: 'library'
]
state.installedVersions << [
name: fileName,
installedVersion: installedVersion,
expectedVersion: expectedVersion,
status: 'Mismatch'
]
} else if(installedVersion) {
logInfo("${fileName} version OK: ${installedVersion}")
state.installedVersions << [
name: fileName,
installedVersion: installedVersion,
expectedVersion: expectedVersion,
status: 'OK'
]
} else {
logInfo("${fileName} not found or could not determine version")
state.installedVersions << [
name: fileName,
installedVersion: 'Not Installed',
expectedVersion: expectedVersion,
status: 'Not Found'
]
}
} else {
// Determine which specific library ID is missing
String missingLibrary = ''
if(fileName.contains('SMAPILibrary')) {
missingLibrary = 'SMAPILibrary ID'
} else if(fileName.contains('UtilitiesAndLoggingLibrary')) {
missingLibrary = 'UtilitiesAndLoggingLibrary ID'
}
logDebug("Library file ${fileName} - no library ID configured for checking")
state.installedVersions << [
name: fileName,
installedVersion: 'N/A',
expectedVersion: expectedVersion,
status: "⚠ Configure ${missingLibrary} in settings"
]
}
}
} else {
// No library IDs configured, skip library version checking
logInfo("Library IDs not configured - skipping library version checks")
manifest.files?.each { file ->
String fileName = file.name
String expectedVersion = appVersion
logDebug("Library file ${fileName} - version checking not available (configure library IDs in settings)")
state.installedVersions << [
name: fileName,
installedVersion: 'N/A',
expectedVersion: expectedVersion,
status: '⚠ Configure Library IDs in settings'
]
}
}
if(state.versionMismatches.size() > 0) {
logInfo("Found ${state.versionMismatches.size()} version mismatch(es)")
} else {
logInfo('All installed files are up to date')
}
}
String getInstalledDriverVersion(String driverName, String namespace) {
String foundVersion = null
try {
// Get authentication cookie
String cookie = login()
if(!cookie) {
logWarn('Failed to authenticate with hub')
return null
}
// Get list of installed drivers - HPM uses /device/drivers
Map params = [
uri: "http://127.0.0.1:8080",
path: '/device/drivers',
headers: [Cookie: cookie]
]
httpGet(params) { resp ->
if(resp?.status == 200) {
logDebug("Driver list response received, checking for ${driverName} in namespace ${namespace}")
// Log all user drivers for debugging
def userDrivers = resp.data?.drivers?.findAll { it.type == 'usr' }
logDebug("Found ${userDrivers?.size()} user drivers")
userDrivers?.each { d ->
logDebug(" - ${d.name} (${d.namespace})")
}
// Find driver by name and namespace
def driver = resp.data?.drivers?.find {
it.type == 'usr' && it?.name == driverName && it?.namespace == namespace
}
if(driver && driver.id) {
Integer driverId = driver.id
logDebug("Found driver ${driverName}, getting source code...")
// Get driver source code using /driver/ajax/code with query parameter
Map codeParams = [
uri: "http://127.0.0.1:8080",
path: '/driver/ajax/code',
headers: [Cookie: cookie],
query: [id: driverId]
]
httpGet(codeParams) { codeResp ->
if(codeResp?.status == 200 && codeResp.data?.source) {
String source = codeResp.data.source
// Extract semantic version from source code for comparison
def matcher = (source =~ /version:\s*['"]([^'"]+)['"]/)
if(matcher.find()) {
foundVersion = matcher.group(1)
logDebug("Extracted version ${foundVersion} from ${driverName}")
} else {
logWarn("Could not find version pattern in source for ${driverName}")
}
} else {
logWarn("Failed to get source code for ${driverName}, status: ${codeResp?.status}")
}
}
} else if(!driver) {
logDebug("Driver not found in hub: ${driverName} (${namespace})")
}
} else {
logWarn("Failed to get driver list, status: ${resp?.status}")
}
}
} catch(Exception e) {
logWarn("Error getting version for ${driverName}: ${e.message}")
}
return foundVersion
}
/**
* Get the installed version of a library from the hub and validate its identity.
* @param libraryId The library ID on the hub
* @param expectedName The expected library name (e.g., 'SMAPILibrary')
* @param expectedNamespace The expected namespace (e.g., 'dwinks')
* @return Map with [version: String, error: String] - version is null if error occurred
*/
Map getInstalledLibraryVersionWithValidation(Integer libraryId, String expectedName, String expectedNamespace) {
Map result = [version: null, error: null]
try {
logInfo("Getting library version for ID ${libraryId}...")
// Get authentication cookie
String cookie = login()
if(!cookie) {
result.error = 'Failed to authenticate with hub'
logWarn(result.error)
return result
}
// Get library code using /library/ajax/code
logInfo("Fetching library code for ID ${libraryId}...")
Map libraryData = getLibraryCode(libraryId, cookie)
logInfo("Library data received: ${libraryData ? 'Yes' : 'No'}, has source: ${libraryData?.source ? 'Yes' : 'No'}")
if(!libraryData?.source) {
result.error = "Library ID ${libraryId} not found on hub"
logWarn(result.error)
return result
}
String source = libraryData.source
// Extract library name and namespace from source
def nameMatch = (source =~ /library\s*\([^)]*name\s*:\s*['"]([^'"]+)['"]/)
def namespaceMatch = (source =~ /library\s*\([^)]*namespace\s*:\s*['"]([^'"]+)['"]/)
String actualName = nameMatch ? nameMatch[0][1] : null
String actualNamespace = namespaceMatch ? namespaceMatch[0][1] : null
logInfo("Library ID ${libraryId} - Expected: ${expectedName}/${expectedNamespace}, Found: ${actualName}/${actualNamespace}")
// Validate library identity
if(actualName != expectedName || actualNamespace != expectedNamespace) {
result.error = "⚠ Library ID ${libraryId} is '${actualName}' (${actualNamespace}), not '${expectedName}' (${expectedNamespace})"
logWarn(result.error)
return result
}
// Extract version from library() definition
result.version = extractLibraryVersion(source)
logInfo("Extracted version ${result.version} from library ${actualName}")
} catch(Exception e) {
result.error = "Error: ${e.message}"
logWarn("Error getting library version for ID ${libraryId}: ${e.message}")
logError("Stack trace:", e)
}
return result
}
String getDriverVersionForUpdate(String driverName, String namespace) {
String hubVersion = null
try {
String cookie = login()
if(!cookie) { return null }
// Get list of installed drivers
Map params = [
uri: "http://127.0.0.1:8080",
path: '/device/drivers',
headers: [Cookie: cookie]
]
httpGet(params) { resp ->
def driver = resp.data?.drivers?.find {
it.type == 'usr' && it?.name == driverName && it?.namespace == namespace
}
if(driver?.id) {
// Get hub's internal version using /driver/ajax/code
Map codeParams = [
uri: "http://127.0.0.1:8080",
path: '/driver/ajax/code',
headers: [Cookie: cookie],
query: [id: driver.id]
]
httpGet(codeParams) { codeResp ->
if(codeResp?.status == 200 && codeResp.data?.version) {
hubVersion = codeResp.data.version.toString()
logDebug("Got hub version ${hubVersion} for ${driverName}")
}
}
}
}
} catch(Exception e) {
logWarn("Error getting hub version for ${driverName}: ${e.message}")
}
return hubVersion
}
void fixVersionMismatches() {
if(!state.versionMismatches || state.versionMismatches.size() == 0) {
logInfo('No version mismatches to fix')
return
}
logInfo("Fixing ${state.versionMismatches.size()} version mismatch(es)...")
// Process libraries FIRST, then drivers (since drivers depend on libraries)
def libraryMismatches = state.versionMismatches.findAll { it.type == 'library' }
def driverMismatches = state.versionMismatches.findAll { it.type == 'driver' }
// Step 1: Update libraries if any are out of date
if(libraryMismatches.size() > 0) {
logInfo("Updating ${libraryMismatches.size()} library/libraries first...")
libraryMismatches.each { mismatch ->
logInfo("Updating library ${mismatch.name} from ${mismatch.installedVersion} to ${mismatch.expectedVersion}...")
// Get current app version to download correct library files
String appVersion = getActualInstalledAppVersion()
String libraryUrl = "https://raw.githubusercontent.com/DanielWinks/Hubitat-Public/v${appVersion}/Libraries/${mismatch.fileName}"
String sourceCode = downloadFile(libraryUrl)
if(sourceCode) {
String libraryVersion = extractLibraryVersion(sourceCode)
Boolean success = publishLibraryToHub(mismatch.libraryId, sourceCode, libraryVersion)
if(success) {
logInfo("Successfully updated library ${mismatch.name}")
} else {
logWarn("Failed to update library ${mismatch.name}")
}
} else {
logWarn("Failed to download source code for library ${mismatch.name} from ${libraryUrl}")
}
}
// Pause to allow hub to process library updates
logInfo("Pausing to allow hub to process library updates...")
pauseExecution(3000)
}
// Step 2: Update drivers after libraries are updated
if(driverMismatches.size() > 0) {
logInfo("Updating ${driverMismatches.size()} driver(s)...")
driverMismatches.each { mismatch ->
logInfo("Updating driver ${mismatch.name} from ${mismatch.installedVersion} to ${mismatch.expectedVersion}...")
String sourceCode = downloadFile(mismatch.location)
if(sourceCode) {
Boolean success = updateDriver(mismatch.name, mismatch.namespace, sourceCode, mismatch.expectedVersion)
if(success) {
logInfo("Successfully updated ${mismatch.name}")
} else {
logWarn("Failed to update ${mismatch.name}")
}
} else {
logWarn("Failed to download source code for ${mismatch.name}")
}
}
}
// Re-check versions after updates
pauseExecution(2000)
checkInstalledVersions()
}
void cleanupDuplicates() {
logInfo("Checking for duplicate apps and drivers...")
// Clear cached cookie to ensure fresh auth
state.remove('hubCookie')
Map results = findAndRemoveDuplicates()
if(results.appsRemoved > 0 || results.driversRemoved > 0) {
state.duplicateCleanupResult = "Removed ${results.appsRemoved} duplicate app(s) and ${results.driversRemoved} duplicate driver(s)"
logInfo(state.duplicateCleanupResult)
} else if(results.errors.size() > 0) {
state.duplicateCleanupResult = "Errors during cleanup: ${results.errors.join(', ')}"
logWarn(state.duplicateCleanupResult)
} else {
state.duplicateCleanupResult = "No duplicates found"
logInfo("No duplicate apps or drivers found")
}
}
/**
* Publish libraries to the hub using the configured library IDs.
* Downloads the latest library files from GitHub and publishes them to the hub.
*/
void publishLibraries() {
logInfo("Publishing libraries to hub...")
// Validate library IDs are configured
if(!settings.libraryIdSMAPI || !settings.libraryIdUtilities) {
logWarn("Library IDs not configured. Please enter both library IDs in settings.")
state.lastLibraryPublish = "Failed: Library IDs not configured"
return
}
// Get current app version to determine which files to publish
String appVersion = getActualInstalledAppVersion()
if(!appVersion) {
logWarn("Failed to determine app version")
state.lastLibraryPublish = "Failed: Could not determine app version"
return
}
logInfo("Publishing libraries for version ${appVersion}")
// Define libraries to publish
List libraries = [
[
id: settings.libraryIdSMAPI as Integer,
name: 'SMAPILibrary',
namespace: 'dwinks',
location: "https://raw.githubusercontent.com/DanielWinks/Hubitat-Public/v${appVersion}/Libraries/SMAPILibrary.groovy"
],
[
id: settings.libraryIdUtilities as Integer,
name: 'UtilitiesAndLoggingLibrary',
namespace: 'dwinks',
location: "https://raw.githubusercontent.com/DanielWinks/Hubitat-Public/v${appVersion}/Libraries/UtilitiesAndLoggingLibrary.groovy"
]
]
Integer successCount = 0
Integer failCount = 0
List errors = []
libraries.each { library ->
logInfo("Publishing ${library.name}...")
try {
// Download library source code
String sourceCode = downloadFile(library.location)
if(!sourceCode) {
String error = "Failed to download ${library.name}"
logWarn(error)
errors << error
failCount++
return
}
// Extract version from library source
String libraryVersion = extractLibraryVersion(sourceCode)
logInfo("Library ${library.name} version: ${libraryVersion}")
// Publish library to hub
Boolean success = publishLibraryToHub(library.id, sourceCode, libraryVersion)
if(success) {
logInfo("Successfully published ${library.name} version ${libraryVersion}")
successCount++
} else {
String error = "Failed to publish ${library.name}"
logWarn(error)
errors << error
failCount++
}
} catch(Exception e) {
String error = "Error publishing ${library.name}: ${e.message}"
logError(error, e)
errors << error
failCount++
}
}
// Update status message
String timestamp = new Date().format('yyyy-MM-dd HH:mm:ss')
if(successCount == libraries.size()) {
state.lastLibraryPublish = "✓ ${timestamp}: Successfully published ${successCount} libraries"
logInfo("Library publish completed successfully: ${successCount} published")
} else if(successCount > 0) {
state.lastLibraryPublish = "⚠ ${timestamp}: Published ${successCount}, failed ${failCount} (${errors.join(', ')})"
logWarn("Library publish partially completed: ${successCount} published, ${failCount} failed")
} else {
state.lastLibraryPublish = "✗ ${timestamp}: Failed to publish any libraries (${errors.join(', ')})"
logWarn("Library publish failed: ${errors.join(', ')}")
}
// Re-check installed versions to reflect the published libraries
if(successCount > 0) {
pauseExecution(1000) // Brief pause to allow hub to process the updates
checkInstalledVersions()
}
}
/**
* Extract version from library source code by parsing the library() definition.
* @param sourceCode The library source code
* @return The version string, or '1.0.0' if not found
*/
String extractLibraryVersion(String sourceCode) {
logWarn("Extracting library version from source code...")
logWarn(sourceCode)
try {
// Look for version: 'x.x.x' or version: "x.x.x" in library() block
def versionMatch = (sourceCode =~ /library\s*\([^)]*version\s*:\s*['"]([\d.]+)['"]/)
if(versionMatch) {
return versionMatch[0][1]
}
} catch(Exception e) {
logWarn("Failed to extract library version: ${e.message}")
}
return '1.0.0' // Default version if not found
}
/**
* Publish a library to the hub using the library ID.
* Uses the same endpoint pattern as apps/drivers: /library/ajax/update
* @param libraryId The library ID on the hub
* @param sourceCode The library source code
* @param version The library version (for reference)
* @return true if successful, false otherwise
*/
Boolean publishLibraryToHub(Integer libraryId, String sourceCode, String version) {
try {
// Get authentication cookie
String cookie = login()
if(!cookie) {
logWarn("Failed to authenticate with hub for library publish")
return false
}
// First, check if library exists and get current version
Map currentLibrary = getLibraryCode(libraryId, cookie)
if(currentLibrary == null) {
logWarn("Library with ID ${libraryId} not found on hub. Please verify the library ID is correct.")
return false
}
logInfo("Current library version on hub: ${currentLibrary.version}")
// Prepare form body for update (same format as apps/drivers)
String body = "id=${libraryId}&version=${currentLibrary.version ?: 0}&source=${java.net.URLEncoder.encode(sourceCode, 'UTF-8')}"
// Post update to hub
Map params = [
uri: "http://127.0.0.1:8080",
path: '/library/ajax/update',
headers: [
'Cookie': cookie,
'Content-Type': 'application/x-www-form-urlencoded'
],
body: body,
timeout: 30,
ignoreSSLIssues: true
]
Map result = null
httpPost(params) { resp ->
if(resp?.status == 200 && resp?.data) {
result = resp.data
logDebug("Library update response: ${result}")
} else {
logWarn("Unexpected response status: ${resp?.status}")
return false
}
}
// Check if update was successful
if(result?.status == 'success') {
logInfo("Library update successful")
return true
} else {
logWarn("Library update failed: ${result?.errorMessage ?: 'Unknown error'}")
return false
}
} catch(Exception e) {
logError("Error publishing library to hub: ${e.message}", e)
return false
}
}
/**
* Get library code from hub by ID.
* @param libraryId The library ID
* @param cookie Authentication cookie
* @return Map with id, version, source, or null if not found
*/
Map getLibraryCode(Integer libraryId, String cookie) {
try {
logInfo("Requesting library code for ID ${libraryId} from /library/ajax/code...")
Map params = [
uri: "http://127.0.0.1:8080",
path: '/library/ajax/code',
query: [id: libraryId.toString()],
headers: ['Cookie': cookie],
timeout: 15,
ignoreSSLIssues: true
]
Map libraryData = null
httpGet(params) { resp ->
logInfo("Library code response status: ${resp?.status}")
if(resp?.status == 200 && resp?.data) {
libraryData = resp.data
logInfo("Library data ID: ${libraryData?.id}, has source: ${libraryData?.source ? 'Yes' : 'No'}")
} else {
logWarn("Unexpected response: status ${resp?.status}, data: ${resp?.data}")
}
}
// Verify the library ID matches (apps return 200 with empty payload when not found)
if(libraryData?.id == libraryId) {
logInfo("Library ID ${libraryId} found and verified")
return libraryData
} else {
logWarn("Library ID mismatch or not found. Requested: ${libraryId}, Received: ${libraryData?.id}")
return null
}
} catch(Exception e) {
logWarn("Error getting library code: ${e.message}")
logError("Stack trace:", e)
return null
}
}
void uploadBundle(Map bundle) {
try {
String cookie = login()
if(!cookie) {
logError('Failed to authenticate with hub')
return
}
logInfo("Uploading bundle: ${bundle.name}")
// Download the bundle ZIP file
byte[] zipData = null
Map params = [
uri: bundle.location,
contentType: 'application/zip',
timeout: 300,
ignoreSSLIssues: true
]
httpGet(params) { resp ->
zipData = resp.data.bytes
}
if(!zipData) {
logWarn("Failed to download bundle ${bundle.name}")
return
}
// Upload using HPM's installFile pattern
String boundary = "----WebKitFormBoundaryDtoO2QfPwfhTjOuS"
String zipFilename = "${bundle.id}-${bundle.name.replaceAll(/[^a-zA-Z0-9]/, '')}.zip"
Map uploadParams = [
uri: "http://127.0.0.1:8080",
path: "/hub/fileManager/upload",
query: ['folder': '/'],
headers: [
'Cookie': cookie,
'Content-Type': "multipart/form-data; boundary=${boundary}"
],
body: """--${boundary}\nContent-Disposition: form-data; name="uploadFile"; filename="${zipFilename}"\nContent-Type: application/zip\n\n${new String(zipData, 'ISO-8859-1')}\n\n--${boundary}\nContent-Disposition: form-data; name="folder"\n\n\n--${boundary}--""",
timeout: 300,
ignoreSSLIssues: true
]
httpPost(uploadParams) { resp ->
if(resp?.status == 200) {
logInfo("Successfully uploaded bundle ${bundle.name}")
}
}
} catch(Exception e) {
logError("Failed to upload bundle ${bundle.name}: ${e.message}")
}
}
Map downloadManifest(String url) {
try {
Map params = [
uri: url,
contentType: 'application/json',
timeout: 15
]
Map manifest = null
httpGet(params) { resp ->
if(resp?.status == 200 && resp.data) {
manifest = resp.data
}
}
return manifest
} catch(Exception e) {
logWarn("Failed to download manifest: ${e.message}")
return null
}
}
// =============================================================================
// Component Methods for Child Event Processing
// =============================================================================
// =============================================================================
// HTTP Helpers
// =============================================================================
void sendLocalCommandAsync(Map args) {
if(args?.endpoint == null && args?.params?.uri == null) { return }
String callbackMethod = args.callbackMethod ?: 'localControlCallback'
Map params = args.params ?: [:]
params.uri = args.params.uri ?: "${getLocalApiPrefix(args.ipAddress)}${args.endpoint}"
params.contentType = args.params.contentType ?: 'application/json'
params.requestContentType = args.params.requestContentType ?: 'application/json'
params.ignoreSSLIssues = args.params.ignoreSSLIssues ?: true
if(params.headers == null) {
params.headers = ['X-Sonos-Api-Key': '123e4567-e89b-12d3-a456-426655440000']
} else if(params.headers != null && params.headers['X-Sonos-Api-Key'] == null) {
params.headers['X-Sonos-Api-Key'] = '123e4567-e89b-12d3-a456-426655440000'
}
logTrace("sendLocalCommandAsync: ${params}")
asynchttpPost(callbackMethod, params, args.data)
}
Map sendLocalJsonQuerySync(Map args) {
if(args?.endpoint == null && args?.params?.uri == null) { return }
Map params = args.params ?: [:]
params.uri = args.params.uri ?: "${getLocalApiPrefix(args.ipAddress)}${args.endpoint}"
params.contentType = args.params.contentType ?: 'application/json'
params.requestContentType = args.params.requestContentType ?: 'application/json'
params.ignoreSSLIssues = args.params.ignoreSSLIssues ?: true
if(params.headers == null) {
params.headers = ['X-Sonos-Api-Key': '123e4567-e89b-12d3-a456-426655440000']
} else if(params.headers != null && params.headers['X-Sonos-Api-Key'] == null) {
params.headers['X-Sonos-Api-Key'] = '123e4567-e89b-12d3-a456-426655440000'
}
logTrace("sendLocalQuerySync: ${params}")
httpGet(params) { resp ->
if (resp && resp.data && resp.success) { return resp.data }
else { logError(resp.data) }
}
}
void sendLocalQueryAsync(Map args) {
if(args?.endpoint == null && args?.params?.uri == null) { return }
String callbackMethod = args.callbackMethod ?: 'localControlCallback'
Map params = args.params ?: [:]
params.uri = args.params.uri ?: "${getLocalApiPrefix(args.ipAddress)}${args.endpoint}"
params.contentType = args.params.contentType ?: 'application/json'
params.requestContentType = args.params.requestContentType ?: 'application/json'
params.ignoreSSLIssues = args.params.ignoreSSLIssues ?: true
if(params.headers == null) {
params.headers = ['X-Sonos-Api-Key': '123e4567-e89b-12d3-a456-426655440000']
} else if(params.headers != null && params.headers['X-Sonos-Api-Key'] == null) {
params.headers['X-Sonos-Api-Key'] = '123e4567-e89b-12d3-a456-426655440000'
}
logTrace("sendLocalQueryAsync: ${params}")
asynchttpGet(callbackMethod, params, args.data)
}
void sendLocalJsonAsync(Map args) {
if(args?.endpoint == null && args?.params?.uri == null) { return }
String callbackMethod = args.callbackMethod ?: 'localControlCallback'
Map params = args.params ?: [:]
params.uri = args.params.uri ?: "${getLocalApiPrefix(args.ipAddress)}${args.endpoint}"
params.contentType = args.params.contentType ?: 'application/json'
params.requestContentType = args.params.requestContentType ?: 'application/json'
params.ignoreSSLIssues = args.params.ignoreSSLIssues ?: true
params.body = JsonOutput.toJson(args.data)
if(params.headers == null) {
params.headers = ['X-Sonos-Api-Key': '123e4567-e89b-12d3-a456-426655440000']
} else if(params.headers != null && params.headers['X-Sonos-Api-Key'] == null) {
params.headers['X-Sonos-Api-Key'] = '123e4567-e89b-12d3-a456-426655440000'
}
logTrace("sendLocalJsonAsync: ${params}")
asynchttpPost(callbackMethod, params)
}
void localControlCallback(AsyncResponse response, Map data) {
if (response?.status != 200 || response.hasError()) {
logError("Request returned HTTP status ${response.status}")
logError("Request error message: ${response.getErrorMessage()}")
try{logError("Request ErrorData: ${response.getErrorData()}")} catch(Exception e){}
try{logErrorJson("Request ErrorJson: ${response.getErrorJson()}")} catch(Exception e){}
try{logErrorXml("Request ErrorXml: ${response.getErrorXml()}")} catch(Exception e){}
}
if(response?.status == 200 && response && response.hasError() == false) {
logTrace("localControlCallback: ${response.getData()}")
}
}
Boolean responseIsValid(AsyncResponse response, String requestName = null) {
if(response?.status == 499) {
try{
Map errData = response.getErrorData()
if(errData?.groupStatus == 'GROUP_STATUS_MOVED') {
ChildDeviceWrapper child = getDeviceFromRincon(errData?.playerId)
if(child) {
child.subscribeToZgtEvents()
logDebug('Resubscribed to ZGT to handle "GROUP_STATUS_MOVED" errors')
}
}
} catch(Exception e){}
} else if (response?.status != 200 || response.hasError()) {
logError("Request returned HTTP status ${response.status}")
logError("Request error message: ${response.getErrorMessage()}")
try{logError("Request ErrorData: ${response.getErrorData()}")} catch(Exception e){}
try{logErrorJson("Request ErrorJson: ${response.getErrorJson()}")} catch(Exception e){}
try{logErrorXml("Request ErrorXml: ${response.getErrorXml()}")} catch(Exception e){}
}
if (response.hasError()) { return false } else { return true }
}
// =============================================================================
// Update Management
// =============================================================================
String getCurrentVersion() {
return app.version ?: '0.7.10'
}
// Get the ACTUAL current version from the hub's app code (not the running instance)
// This is important after updates, as the running instance may have the old version
String getActualInstalledAppVersion() {
try {
String cookie = login()
if(!cookie) {
logWarn('Failed to authenticate to get app version')
return getCurrentVersion() // Fallback to instance version
}
// Find this app's code ID
List allApps = getAppCodeList()
Map targetApp = allApps.find { it.name == 'Sonos Advanced Controller' && it.namespace == 'dwinks' }
if(!targetApp) {
logWarn('Could not find app code entry')
return getCurrentVersion() // Fallback to instance version
}
// Query the app code to get its version
Map params = [
uri: "http://127.0.0.1:8080",
path: '/app/ajax/code',
headers: ['Cookie': cookie],
query: [id: targetApp.id],
timeout: 15,
ignoreSSLIssues: true
]
String version = null
httpGet(params) { resp ->
if(resp?.status == 200 && resp.data?.source) {
// Parse the version from the source code definition
def matcher = resp.data.source =~ /version:\s*['"]([^'"]+)['"]/
if(matcher) {
version = matcher[0][1]
}
}
}
if(version) {
logDebug("Got actual installed app version from hub: ${version}")
return version
} else {
logWarn('Could not parse version from app code')
return getCurrentVersion() // Fallback to instance version
}
} catch(Exception e) {
logError("Error getting actual app version: ${e.message}")
return getCurrentVersion() // Fallback to instance version
}
}
void checkForUpdates(Boolean manual = false) {
// Check if auto-check is enabled or if this is a manual check
if(!manual && autoCheckUpdates != true) { return }
// Check frequency limits for automatic checks
if(!manual && state.lastUpdateCheck) {
Long lastCheck = state.lastUpdateCheck as Long
Long now = now()
Long dayInMs = 86400000L
Long weekInMs = dayInMs * 7
if(updateCheckFrequency == 'Daily' && (now - lastCheck) < dayInMs) { return }
if(updateCheckFrequency == 'Weekly' && (now - lastCheck) < weekInMs) { return }
}
try {
String manifestUrl = 'https://raw.githubusercontent.com/DanielWinks/Hubitat-Public/main/PackageManifests/SonosAdvancedController/packageManifest.json'
Map params = [
uri: manifestUrl,
contentType: 'application/json',
timeout: 15
]
httpGet(params) { resp ->
if(resp?.status == 200 && resp.data) {
Map manifest = resp.data
String latestVersion = manifest.version ?: null
if(latestVersion) {
String currentVersion = getActualInstalledAppVersion()
Integer comparison = compareVersions(currentVersion, latestVersion)
state.lastUpdateCheck = now()
state.lastUpdateCheckFormatted = new Date().format('yyyy-MM-dd HH:mm:ss')
if(comparison < 0) {
// Update available
state.updateAvailable = true
state.latestVersion = latestVersion
state.latestManifest = manifest
state.latestReleaseDate = manifest.releaseDate ?: 'Unknown'
state.latestReleaseUrl = "https://github.com/DanielWinks/Hubitat-Public/releases/tag/v${latestVersion}"
// Update app label - keep simple since updateLabel doesn't render HTML
app.updateLabel("Sonos Advanced Controller [Update Available]")
logInfo("Update available: ${latestVersion} (current: ${currentVersion})")
if(manual) {
// Force page refresh to show update notification
runIn(1, 'refreshPage')
}
// Auto-install if enabled
if(autoInstallUpdates == true && !manual) {
scheduleAutoInstall()
}
} else {
state.updateAvailable = false
state.latestVersion = null
state.latestManifest = null
state.latestReleaseDate = null
state.latestReleaseUrl = null
// Clear update label
app.updateLabel("Sonos Advanced Controller")
if(manual) {
logInfo("No updates available. Current version ${currentVersion} is up to date.")
}
}
}
}
}
} catch(Exception e) {
logError("Error checking for updates: ${e.message}")
state.lastUpdateCheck = now()
state.lastUpdateCheckFormatted = new Date().format('yyyy-MM-dd HH:mm:ss')
}
}
void refreshPage() {
// Dummy method to trigger page refresh
}
Integer compareVersions(String version1, String version2) {
// Remove 'v' prefix if present
version1 = version1?.replaceAll(/^v/, '') ?: '0.0.0'
version2 = version2?.replaceAll(/^v/, '') ?: '0.0.0'
List parts1 = version1.tokenize('.')
List parts2 = version2.tokenize('.')
Integer maxLength = Math.max(parts1.size(), parts2.size())
for(Integer i = 0; i < maxLength; i++) {
Integer num1 = i < parts1.size() ? (parts1[i] as Integer) : 0
Integer num2 = i < parts2.size() ? (parts2[i] as Integer) : 0
if(num1 < num2) { return -1 }
if(num1 > num2) { return 1 }
}
return 0
}
void installUpdate() {
if(!state.updateAvailable || !state.latestManifest) {
logError('No update available to install')
return
}
try {
// First, check for and remove any duplicate apps/drivers that may have been created
logDebug("Checking for duplicate app/driver entries...")
Map duplicateResults = findAndRemoveDuplicates()
if(duplicateResults.appsRemoved > 0 || duplicateResults.driversRemoved > 0) {
logInfo("Cleaned up ${duplicateResults.appsRemoved} duplicate apps and ${duplicateResults.driversRemoved} duplicate drivers")
}
if(duplicateResults.errors.size() > 0) {
logWarn("Some errors occurred during duplicate cleanup: ${duplicateResults.errors}")
}
// Clear cached cookie to ensure fresh auth for updates
state.remove('hubCookie')
Map manifest = state.latestManifest
String version = state.latestVersion
logInfo("Starting update to version ${version}...")
// Track what needs updating
Map updateStatus = [
app: false,
drivers: [:],
errors: []
]
// Update the main app
if(manifest.apps && manifest.apps.size() > 0) {
Map appInfo = manifest.apps[0]
String appLocation = appInfo.location
logDebug("Downloading app from: ${appLocation}")
String appCode = downloadFile(appLocation)
if(appCode) {
logDebug("Downloaded app code (${appCode.length()} bytes)")
Boolean success = updateThisApp(appCode, version)
updateStatus.app = success
if(success) {
logInfo("App updated successfully to version ${version}")
// Trigger page refresh after successful app update
runIn(2, 'refreshPage')
} else {
updateStatus.errors << "Failed to update app"
logError("Failed to update app - check logs for details")
// Don't stop - continue with driver updates even if app fails
}
} else {
updateStatus.errors << "Failed to download app code"
logError("Failed to download app code from ${appLocation}")
}
}
// Update drivers
if(manifest.drivers) {
manifest.drivers.each { driverInfo ->
String driverName = driverInfo.name
String driverLocation = driverInfo.location
String driverNamespace = driverInfo.namespace ?: 'dwinks'
logDebug("Downloading driver ${driverName} from: ${driverLocation}")
String driverCode = downloadFile(driverLocation)
if(driverCode) {
logDebug("Downloaded driver ${driverName} code (${driverCode.length()} bytes)")
Boolean success = updateDriver(driverName, driverNamespace, driverCode, version)
updateStatus.drivers[driverName] = success
if(success) {
logInfo("Driver ${driverName} updated successfully")
} else {
updateStatus.errors << "Failed to update driver: ${driverName}"
logError("Failed to update driver: ${driverName} - check logs for details")
}
} else {
updateStatus.errors << "Failed to download driver: ${driverName}"
logError("Failed to download driver: ${driverName} from ${driverLocation}")
}
}
}
// Clear update notification if successful
if(updateStatus.errors.size() == 0) {
state.updateAvailable = false
state.latestVersion = null
state.latestManifest = null
state.latestReleaseDate = null
state.latestReleaseUrl = null
// Clear stale version check data - the page will refresh and new code will run
// User can re-run version check after refresh to see current state
state.remove('versionMismatches')
state.remove('installedVersions')
state.remove('lastVersionCheck')
// Set flag to show success message after page refresh
state.updateJustCompleted = true
// Clear update label
app.updateLabel("Sonos Advanced Controller")
// Clear any scheduled auto-install
unschedule('performScheduledInstall')
logInfo("Update completed successfully! The app will refresh in 3 seconds...")
// Force page refresh to load new code
runIn(3, 'refreshPage')
} else {
logError("Update completed with errors: ${updateStatus.errors.join(', ')}")
// Even with errors, if drivers updated, recommend checking versions
if(updateStatus.drivers.any { it.value == true }) {
logInfo("Some drivers were updated successfully. The page will refresh in 3 seconds...")
runIn(3, 'refreshPage')
}
}
} catch(Exception e) {
logError("Error installing update: ${e.message}")
}
}
String downloadFile(String uri) {
try {
Map params = [
uri: uri,
contentType: 'text/plain',
timeout: 30
]
String fileContent = null
httpGet(params) { resp ->
if(resp?.status == 200) {
fileContent = resp.data.text
}
}
return fileContent
} catch(Exception e) {
logError("Error downloading file from ${uri}: ${e.message}")
return null
}
}
Boolean updateThisApp(String sourceCode, String newVersionForLogging) {
try {
String cookie = login()
if(!cookie) {
logError('Failed to authenticate with hub')
return false
}
// Find the app code ID by matching name and namespace
// Note: app.id is the INSTANCE id, not the CODE id. We need the code ID from /hub2/userAppTypes
List allApps = getAppCodeList()
String thisAppName = 'Sonos Advanced Controller'
String thisAppNamespace = 'dwinks'
Map targetApp = allApps.find { appEntry ->
appEntry.name == thisAppName && appEntry.namespace == thisAppNamespace
}
if(!targetApp) {
logError("Could not find app code for ${thisAppName} in namespace ${thisAppNamespace}")
logDebug("Available apps: ${allApps}")
return false
}
String appCodeId = targetApp.id
logDebug("Found app code ID: ${appCodeId} for ${thisAppName}")
// Get the app's current internal version from the hub (HPM uses this for updates)
Integer currentHubVersion = null
Map getAppParams = [
uri: "http://127.0.0.1:8080",
path: '/app/ajax/code',
requestContentType: 'application/x-www-form-urlencoded',
headers: ['Cookie': cookie],
query: [id: appCodeId],
timeout: 300,
ignoreSSLIssues: true
]
try {
httpGet(getAppParams) { getResp ->
logDebug("App code response status: ${getResp?.status}")
logDebug("App code response data: ${getResp.data}")
if(getResp?.status == 200 && getResp.data?.version != null) {
currentHubVersion = getResp.data.version as Integer
logDebug("Got current app version from hub: ${currentHubVersion}")
} else {
logWarn("Could not get app version from hub - response: ${getResp.data}")
}
}
} catch(Exception e) {
logError("Error getting app version: ${e.message}")
}
// If we couldn't get the version, we cannot update
if(currentHubVersion == null) {
logError("Cannot update app: unable to retrieve current version from hub")
return false
}
logDebug("Updating app to version ${newVersionForLogging}, hub internal version: ${currentHubVersion}")
// HPM uses /app/ajax/update for existing apps with version parameter
Map params = [
uri: "http://127.0.0.1:8080",
path: '/app/ajax/update',
requestContentType: 'application/x-www-form-urlencoded',
headers: [
'Connection': 'keep-alive',
'Cookie': cookie
],
body: [
id: appCodeId,
version: currentHubVersion,
source: sourceCode
],
timeout: 420,
ignoreSSLIssues: true
]
Boolean result = false
httpPost(params) { resp ->
logDebug("App update response: ${resp.data}")
// HPM checks for resp.data.status == "success"
if(resp.data?.status == 'success') {
logInfo("App updated successfully to version ${newVersionForLogging}")
result = true
} else {
logError("App update failed - response: ${resp.data}")
result = false
}
}
return result
} catch(Exception e) {
logError("Error updating app: ${e.message}")
return false
}
}
Boolean updateDriver(String driverName, String namespace, String sourceCode, String newVersionForLogging) {
try {
String cookie = login()
if(!cookie) {
logError('Failed to authenticate with hub')
return false
}
// Find the driver by name and namespace
List allDrivers = getDriverList()
Map targetDriver = allDrivers.find { driver ->
driver.name == driverName && driver.namespace == namespace
}
if(!targetDriver) {
// Driver doesn't exist - create it using /driver/save (HPM pattern)
logInfo("Driver not found, creating new driver: ${namespace}.${driverName}")
Map createParams = [
uri: "http://127.0.0.1:8080",
path: '/driver/save',
requestContentType: 'application/x-www-form-urlencoded',
headers: [
'Cookie': cookie
],
body: [
id: '',
version: '',
create: '',
source: sourceCode
],
timeout: 300,
ignoreSSLIssues: true
]
Boolean result = false
httpPost(createParams) { resp ->
// HPM checks for Location header on successful create
if(resp.headers?.Location != null) {
String newId = resp.headers.Location.replaceAll("https?://127.0.0.1:(?:8080|8443)/driver/editor/", "")
logInfo("Successfully created driver ${driverName} with id ${newId}")
result = true
} else {
logError("Driver ${driverName} creation failed - no Location header")
result = false
}
}
return result
}
// Driver exists - update it using /driver/ajax/update (HPM pattern)
logInfo("Updating existing driver ${driverName} (id: ${targetDriver.id})")
// Get driver's current version
String currentHubVersion = getDriverVersionForUpdate(driverName, namespace) ?: ''
logDebug("Updating driver ${driverName}: hub version=${currentHubVersion}, new version=${newVersionForLogging}")
Map updateParams = [
uri: "http://127.0.0.1:8080",
path: '/driver/ajax/update',
requestContentType: 'application/x-www-form-urlencoded',
headers: [
'Cookie': cookie
],
body: [
id: targetDriver.id,
version: currentHubVersion,
source: sourceCode
],
timeout: 300,
ignoreSSLIssues: true
]
Boolean result = false
httpPost(updateParams) { resp ->
logDebug("Driver update response: ${resp.data}")
// HPM checks for resp.data.status == "success"
if(resp.data?.status == 'success') {
logInfo("Successfully updated driver ${driverName}")
result = true
} else {
logError("Driver ${driverName} update failed - response: ${resp.data}")
result = false
}
}
return result
} catch(Exception e) {
logError("Error updating/creating driver ${driverName}: ${e.message}")
return false
}
}
List getDriverList() {
try {
String cookie = login()
if(!cookie) { return [] }
Map params = [
uri: "http://127.0.0.1:8080",
path: '/device/drivers',
headers: [
'Cookie': cookie
],
timeout: 15
]
List drivers = []
httpGet(params) { resp ->
if(resp?.status == 200 && resp.data?.drivers) {
// Filter to only user drivers
drivers = resp.data.drivers.findAll { it.type == 'usr' }
}
}
return drivers
} catch(Exception e) {
logError("Error getting driver list: ${e.message}")
return []
}
}
// Get list of app code entries (not installed app instances)
List getAppCodeList() {
try {
String cookie = login()
if(!cookie) { return [] }
// Use /hub2/userAppTypes endpoint like HPM does (requires 2.3.6+)
Map params = [
uri: "http://127.0.0.1:8080",
path: '/hub2/userAppTypes',
headers: [
'Cookie': cookie
],
timeout: 15,
ignoreSSLIssues: true
]
List apps = []
httpGet(params) { resp ->
if(resp?.status == 200 && resp.data) {
resp.data.each { appEntry ->
apps << [id: appEntry.id.toString(), name: appEntry.name, namespace: appEntry.namespace]
}
}
}
return apps
} catch(Exception e) {
logError("Error getting app code list: ${e.message}")
return []
}
}
// Find and remove duplicate app or driver entries (keeps the one with lowest ID, removes others)
Map findAndRemoveDuplicates() {
Map results = [appsRemoved: 0, driversRemoved: 0, errors: []]
try {
String cookie = login()
if(!cookie) {
results.errors << "Failed to authenticate"
return results
}
// Check for duplicate apps
List allApps = getAppCodeList()
Map appsByNamespace = [:].withDefault { [] }
allApps.each { appEntry ->
String key = "${appEntry.namespace}:${appEntry.name}"
appsByNamespace[key] << appEntry
}
appsByNamespace.each { key, entries ->
if(entries.size() > 1) {
logWarn("Found ${entries.size()} duplicate apps for ${key}")
// Sort by ID (keep lowest, which is usually the original)
entries.sort { Integer.parseInt(it.id) }
// Remove all but the first (lowest ID)
entries.drop(1).each { duplicate ->
logInfo("Removing duplicate app: ${duplicate.name} (id: ${duplicate.id})")
if(uninstallAppCode(duplicate.id, cookie)) {
results.appsRemoved++
} else {
results.errors << "Failed to remove app ${duplicate.name} (id: ${duplicate.id})"
}
}
}
}
// Check for duplicate drivers
List allDrivers = getDriverList()
Map driversByNamespace = [:].withDefault { [] }
allDrivers.each { driverEntry ->
String key = "${driverEntry.namespace}:${driverEntry.name}"
driversByNamespace[key] << driverEntry
}
driversByNamespace.each { key, entries ->
if(entries.size() > 1) {
logWarn("Found ${entries.size()} duplicate drivers for ${key}")
// Sort by ID (keep lowest, which is usually the original)
entries.sort { it.id as Integer }
// Remove all but the first (lowest ID)
entries.drop(1).each { duplicate ->
logInfo("Removing duplicate driver: ${duplicate.name} (id: ${duplicate.id})")
if(uninstallDriverCode(duplicate.id.toString(), cookie)) {
results.driversRemoved++
} else {
results.errors << "Failed to remove driver ${duplicate.name} (id: ${duplicate.id})"
}
}
}
}
} catch(Exception e) {
logError("Error finding/removing duplicates: ${e.message}")
results.errors << e.message
}
if(results.appsRemoved > 0 || results.driversRemoved > 0) {
logInfo("Removed ${results.appsRemoved} duplicate apps and ${results.driversRemoved} duplicate drivers")
}
return results
}
// Uninstall app code (not app instance) - uses HPM-style endpoint
Boolean uninstallAppCode(String appCodeId, String cookie) {
try {
// Use newer endpoint if available (2.3.8+), fallback to older method
Map params = [
uri: "http://127.0.0.1:8080",
path: "/app/edit/deleteJsonSafe/${appCodeId}",
headers: [
'Cookie': cookie
],
timeout: 300,
ignoreSSLIssues: true
]
Boolean result = false
httpGet(params) { resp ->
if(resp.data?.status == true) {
result = true
}
}
return result
} catch(Exception e) {
logError("Error uninstalling app code ${appCodeId}: ${e.message}")
return false
}
}
// Uninstall driver code - uses HPM-style endpoint
Boolean uninstallDriverCode(String driverCodeId, String cookie) {
try {
// Use newer endpoint if available (2.3.7+)
Map params = [
uri: "http://127.0.0.1:8080",
path: "/driver/editor/deleteJson/${driverCodeId}",
headers: [
'Cookie': cookie
],
timeout: 300,
ignoreSSLIssues: true
]
Boolean result = false
httpGet(params) { resp ->
if(resp.data?.status == true) {
result = true
}
}
return result
} catch(Exception e) {
logError("Error uninstalling driver code ${driverCodeId}: ${e.message}")
return false
}
}
String login() {
// If we already have a valid cookie, try to reuse it
if(state.hubCookie) {
logDebug("Reusing existing cookie")
return state.hubCookie
}
try {
Map params = [
uri: "http://127.0.0.1:8080",
path: '/login',
requestContentType: 'application/x-www-form-urlencoded',
body: [
username: '',
password: '',
submit: 'Login'
],
followRedirects: false,
textParser: true,
timeout: 15
]
String cookie = null
httpPost(params) { resp ->
logDebug("Login response status: ${resp?.status}")
if(resp?.status == 200 || resp?.status == 302) {
def setCookieHeader = resp.headers['Set-Cookie']
if(setCookieHeader) {
String cookieValue = setCookieHeader.value ?: setCookieHeader.toString()
cookie = cookieValue.split(';')[0]
state.hubCookie = cookie // Store for reuse like HPM
logDebug("Got cookie: ${cookie?.take(20)}...")
} else {
logWarn("No Set-Cookie header in login response")
}
} else {
logWarn("Unexpected login status: ${resp?.status}")
}
}
if(!cookie) {
logWarn("Failed to get authentication cookie")
}
return cookie
} catch(Exception e) {
logError("Login error: ${e.message}")
return null
}
}
void scheduleUpdateCheck() {
unschedule('checkForUpdates')
if(autoCheckUpdates == true) {
if(updateCheckFrequency == 'Daily') {
schedule('0 0 2 * * ?', 'checkForUpdates') // 2 AM daily
} else if(updateCheckFrequency == 'Weekly') {
schedule('0 0 2 ? * MON', 'checkForUpdates') // 2 AM Monday
}
}
}
void scheduleAutoInstall() {
if(!autoInstallUpdates || !autoInstallTime || !state.updateAvailable) {
unschedule('performScheduledInstall')
return
}
try {
Date installTime = timeToday(autoInstallTime, location.timeZone)
String cronExpression
if(autoInstallNextOccurrence == true) {
// Install at next occurrence of time, regardless of day
cronExpression = "0 ${installTime.minutes} ${installTime.hours} * * ?"
Date now = new Date()
if(installTime.before(now)) {
logDebug("Auto-install time has passed today, will install tomorrow at ${autoInstallTime}")
} else {
logDebug("Auto-install scheduled for today at ${autoInstallTime}")
}
} else {
// Install on specific day of week
String dayOfWeek = autoInstallDayOfWeek ?: 'Sunday'
Map dayMap = [
'Sunday': 'SUN',
'Monday': 'MON',
'Tuesday': 'TUE',
'Wednesday': 'WED',
'Thursday': 'THU',
'Friday': 'FRI',
'Saturday': 'SAT'
]
String cronDay = dayMap[dayOfWeek]
cronExpression = "0 ${installTime.minutes} ${installTime.hours} ? * ${cronDay}"
logDebug("Auto-install scheduled for ${dayOfWeek}s at ${autoInstallTime}")
}
schedule(cronExpression, 'performScheduledInstall')
} catch(Exception e) {
logError("Error scheduling auto-install: ${e.message}")
}
}
void performScheduledInstall() {
if(!state.updateAvailable) {
logInfo("Scheduled install called but no update available")
return
}
logInfo("Performing scheduled automatic update installation...")
installUpdate()
}