/** * Auto Room Sorter * * Detects room names in device labels, creates missing rooms on the hub, * and assigns unassigned devices — with preview, exclusions, leftovers, * and one-level undo. * * Uses hub-local internal endpoints (verified on firmware 2.5.1.x): * GET /room/listRoomsJson * GET /hub2/devicesList * POST /room/save (JSON; create room, or replace room membership with full deviceIds list) * GET /device/setRoom?deviceId=&roomId= * GET /room/delete/{id} * * Never uses /device/updateRoom (name-based; can create junk rooms). * * Version: 1.3.8 */ definition( name: "Auto Room Sorter", namespace: "ephrayim", author: "Ephrayim", description: "Detect room names in device labels, create missing rooms, and sort unassigned devices into them.", category: "Convenience", iconUrl: "", iconX2Url: "", iconX3Url: "", singleInstance: true, importUrl: "https://raw.githubusercontent.com/evdev/Hubitat_auto_device-room_sorter/main/AutoRoomSorter.groovy" ) preferences { page(name: "mainPage") page(name: "roomsPage") page(name: "previewPage") page(name: "leftoversPage") page(name: "resultsPage") } // --------------------------------------------------------------------------- // Pages // --------------------------------------------------------------------------- def mainPage() { if (state.acknowledgedRisk == null) state.acknowledgedRisk = false def probe = probeHubEndpoints() state.lastProbeOk = probe.ok state.lastProbeMessage = probe.message if (probe.ok) { refreshScanIntoState() } dynamicPage(name: "mainPage", title: "Auto Room Sorter", install: true, uninstall: true) { section("Status") { if (probe.ok) { paragraph calloutBox("✓ Connected — ${escapeHtml(probe.message)}", "success") } else { paragraph calloutBox("✗ Not ready — ${escapeHtml(probe.message)}", "danger") } } if (probe.ok) { def plan = state.roomPlan ?: [] def matches = state.matchPreview ?: [] def proposed = matches.findAll { it.proposedRoomKey } def already = (state.deviceSnapshot ?: []).findAll { it.roomId } def unassigned = (state.deviceSnapshot ?: []).findAll { !it.roomId } def newRoomsToCreate = 0 plan.eachWithIndex { room, idx -> if (!room.hubRoomId && roomIncludeValue(idx, room)) newRoomsToCreate++ } section("Scan summary") { paragraph """Total devices: ${(state.deviceSnapshot ?: []).size()}
Already assigned: ${already.size()} (never moved)
Unassigned: ${unassigned.size()}
Proposed matches: ${proposed.size()}
Detected rooms: ${plan.size()}""" def nextHint if (!acknowledgedRisk) { nextHint = "First, check the safety acknowledgment below, then continue with Step 1." } else if (newRoomsToCreate > 0) { nextHint = "→ Next: Step 1 — review ${newRoomsToCreate} new room(s) before sorting." } else if (proposed) { nextHint = "→ Next: Step 2 — preview ${proposed.size()} match(es) and sort devices." } else if (unassigned) { nextHint = "→ No auto-matches yet. Open Step 1 to tweak aliases, or Step 2 → Leftovers for manual assignment." } else { nextHint = "→ All devices already have rooms. Nothing to sort right now." } paragraph calloutBox(nextHint, "info") } } section("Safety") { if (acknowledgedRisk) { paragraph calloutBox("✓ Safety acknowledged — create & sort actions are unlocked. Prefer a hub backup before large sorts.", "success") } else { paragraph calloutBox("Before you begin: This app writes to undocumented hub endpoints to create rooms and assign devices. Behavior can change with firmware updates. Prefer a hub backup before large sorts.", "warning") } input "acknowledgedRisk", "bool", title: "I understand this app writes to undocumented hub endpoints", defaultValue: false, submitOnChange: true } if (probe.ok) { section("Workflow") { paragraph "Work through the steps in order. Already-assigned devices are never moved." href "roomsPage", title: "Step 1 of 2 — Review / create rooms", description: "Confirm detected rooms, edit aliases, and create any missing ones" href "previewPage", title: "Step 2 of 2 — Preview & sort devices", description: "Review matches, exclude devices or rooms, then sort (also works if rooms already exist)" if (state.lastRun?.undo) { href "resultsPage", title: "Last run / Undo", description: "See results from the last sort, or undo it" } } } section("Advanced settings") { input "excludeChildren", "bool", title: "Exclude child / component devices", defaultValue: false, submitOnChange: true input "excludeVirtual", "bool", title: "Exclude virtual devices", defaultValue: false, submitOnChange: true input "inheritParentRoom", "bool", title: "Propose parent's room for unmatched child devices", defaultValue: true, submitOnChange: true input "logEnable", "bool", title: "Enable debug logging (Logs → Apps)", defaultValue: false input "hubSecurity", "bool", title: "Hub login security enabled", defaultValue: false, submitOnChange: true if (hubSecurity) { input "hubUsername", "string", title: "Username", required: true input "hubPassword", "password", title: "Password", required: true } } } } def roomsPage() { if (!state.lastProbeOk) { return dynamicPage(name: "roomsPage", title: "Rooms") { section { paragraph calloutBox("Hub probe failed. Return to the main page.", "danger") } } } refreshScanIntoState() applyRoomPageEdits() def plan = state.roomPlan ?: [] def existingRooms = [] def newRooms = [] plan.eachWithIndex { room, idx -> def entry = [room: room, idx: idx] if (room.hubRoomId) existingRooms << entry else newRooms << entry } def willCreate = newRooms.count { roomIncludeValue(it.idx, it.room) } def collisions = findAliasCollisions(plan) def nameCollisions = findRoomNameCollisions(plan, state.hubRooms ?: []) dynamicPage(name: "roomsPage", title: "Step 1 — Rooms", nextPage: "mainPage") { section { emitAliasFeedbackBanner() if (state.lastAddRoomMessage) { paragraph calloutBox( "${state.lastAddRoomOk ? '✓ Added to plan' : '✗ Not added'}
${escapeHtml(state.lastAddRoomMessage.toString())}", state.lastAddRoomOk ? "success" : "warning" ) } paragraph "Edit aliases below, then tap Save aliases. A green confirmation appears at the top of this page (Hubitat apps cannot show phone toasts). Clear aliases on a room wipes the pre-filled list." input "btnSaveAliases", "button", title: "Save aliases" } section("Overview") { paragraph calloutBox( "${existingRooms.size()} existing · ${newRooms.size()} new detected · ${willCreate} will be created", "info" ) paragraph """${badge("Exists", "#2e7d32")} Reuse the hub room (never renamed)
${badge("New", "#ef6c00")} Will create if checked
${badge("Skipped", "#757575")} New room unchecked — won't create or auto-match""" if (collisions) { paragraph calloutBox("Alias collisions: ${escapeHtml(collisions.join('; '))}", "danger") } if (nameCollisions) { paragraph calloutBox("Duplicate room names: ${escapeHtml(nameCollisions.join('; '))}", "warning") } } if (!plan) { section("Detected rooms") { paragraph calloutBox("No rooms detected from device labels yet. Add a custom room below, or go to Step 2 if your hub rooms already exist.", "info") } } if (existingRooms) { section("Existing hub rooms") { existingRooms.each { entry -> def room = entry.room def idx = entry.idx paragraph "${escapeHtml(room.canonicalName)} — ${badge("Exists", "#2e7d32")} will reuse · ${room.matchCount ?: 0} matching device(s)" paragraph "Name (read-only): ${escapeHtml(room.canonicalName)}" emitAliasInput(idx, room) } } } if (newRooms) { section("Newly detected rooms") { newRooms.each { entry -> def room = entry.room def idx = entry.idx def included = roomIncludeValue(idx, room) def statusBadge = included ? badge("New", "#ef6c00") : badge("Skipped", "#757575") def statusText = included ? "will create" : "unchecked — skipped" paragraph "${escapeHtml(room.canonicalName)} — ${statusBadge} ${statusText} · ${room.matchCount ?: 0} matching device(s)" input roomIncludeSettingName(room), "bool", title: "Create this room", defaultValue: true, submitOnChange: true def nameKey = roomNameSettingName(room) def nameVal = settings[nameKey] if (nameVal == null) nameVal = room.canonicalName input nameKey, "text", title: "Room name", defaultValue: nameVal.toString(), required: true, submitOnChange: true def sameNameCount = plan.count { normalizeLabel(it.canonicalName) == normalizeLabel(room.canonicalName) } if (sameNameCount > 1) { paragraph calloutBox("Another room in the plan uses this same name. Create will make only one room.", "warning") } emitAliasInput(idx, room) } } } applyRoomPageEdits() if (plan) { section { emitAliasFeedbackBanner() input "btnSaveAliasesBottom", "button", title: "Save aliases" } } section("Add another room") { paragraph "Enter a custom name or pick from the catalog, then tap Add room to plan." if (state.lastAddRoomMessage) { paragraph calloutBox(escapeHtml(state.lastAddRoomMessage.toString()), state.lastAddRoomOk ? "success" : "warning") } input "addRoomName", "text", title: "Custom room name", required: false input "addRoomAliases", "text", title: "Aliases (comma-separated)", required: false def catalogOpts = new LinkedHashMap() catalogOpts["__none__"] = "(none)" roomCatalog().keySet().sort().each { catalogOpts[it] = it } input "addFromCatalog", "enum", title: "Or pick from catalog", options: catalogOpts, required: false input "btnAddRoom", "button", title: "Add room to plan" } section("Actions") { if (!acknowledgedRisk) { paragraph calloutBox("Acknowledge the safety checkbox on the main page before creating rooms.", "danger") } else { paragraph calloutBox("Create Rooms writes to the hub via undocumented endpoints. A room name that already exists (ignoring case and punctuation) is reused — never duplicated or renamed.", "warning") input "btnCreateRooms", "button", title: "Create Rooms" } if (state.lastCreateLog) { def logKind = state.lastCreateLog.toString().contains("ERROR") || state.lastCreateLog.toString().contains("FAILED") ? "warning" : "success" paragraph calloutBox("Last create result
${escapeHtml(state.lastCreateLog.toString())}
", logKind) } } } } def previewPage() { if (!state.lastProbeOk) { return dynamicPage(name: "previewPage", title: "Preview") { section { paragraph calloutBox("Hub probe failed. Return to the main page.", "danger") } } } clearStaleApplyLock() if (!state.applyInProgress) { refreshScanIntoState() } def matches = (state.matchPreview ?: []).findAll { it.proposedRoomKey } def byRoom = matches.groupBy { it.proposedRoomName } def roomOrder = byRoom.keySet().sort { it?.toLowerCase() } state.sortRoomOrder = roomOrder applySortRoomIncludes() applySortRoomTargets() def roomOpts = sortTargetRoomOptions() def excluded = (excludedDeviceIds ?: []).collect { it.toString() } as Set def excludeOptions = matches.findAll { row -> sortRoomIncluded(row.proposedRoomName) }.collectEntries { row -> def resolved = resolveSortTarget(row.proposedRoomName) def targetLabel = resolved?.roomName ?: "(will skip)" def suffix = row.ambiguous ? " (ambiguous)" : "" if (row.matchedAlias == "(inherited)") suffix += " (inherited)" [(row.deviceId.toString()): "${row.deviceName} → ${targetLabel}${suffix}"] } def includedRooms = roomOrder.findAll { sortRoomIncluded(it) && resolveSortTarget(it) }.collect { resolveSortTarget(it).roomName }.unique() def willSortCount = matches.count { row -> sortRoomIncluded(row.proposedRoomName) && !excluded.contains(row.deviceId.toString()) && resolveSortTarget(row.proposedRoomName) } def ambiguousCount = matches.count { it.ambiguous } def missingRoomGroups = roomOrder.findAll { roomName -> !suggestedRoomIdForGroup(roomName, byRoom[roomName]) && effectiveSortTarget(roomName) == sortTargetSkipValue() } def missingRoomCount = missingRoomGroups.size() def refresh = state.applyInProgress ? 5 : 0 dynamicPage(name: "previewPage", title: "Step 2 — Preview & sort", refreshInterval: refresh, nextPage: "mainPage") { section("Overview") { paragraph calloutBox( "${matches.size()} matched device(s) across ${byRoom.size()} room(s) · ${excluded.size()} excluded · ${willSortCount} ready to sort", "info" ) paragraph """${badge("will sort", "#2e7d32")} Room checked, target room selected, devices included
${badge("skipped", "#757575")} Room unchecked — nothing sorted into it
${badge("will skip", "#c62828")} Target is “room not created yet” — create in Step 1 or pick another room
ambiguous — two rooms tied for best match; review before sorting
(inherited) — unmatched child proposed from parent's room""" if (missingRoomCount) { paragraph calloutBox( "These rooms are not on the hub yet — run Step 1 → Create Rooms before sorting into them (or pick another target room): ${escapeHtml(missingRoomGroups.join(", "))}", "warning" ) } if (ambiguousCount) { paragraph calloutBox("${ambiguousCount} ambiguous match(es). Consider excluding them or refining aliases in Step 1.", "warning") } } section("Proposed assignments (grouped by room)") { if (!byRoom) { paragraph calloutBox("No automatic matches among unassigned devices. Use Leftovers to assign manually, or adjust aliases in Step 1.", "info") } else { paragraph "Uncheck a room to skip it, or change Target room to override the auto suggestion. You can still exclude individual devices below." roomOrder.each { roomName -> def devices = byRoom[roomName] def included = sortRoomIncluded(roomName) def suggestedId = suggestedRoomIdForGroup(roomName, devices) def defaultTarget = defaultSortTarget(roomName, devices) def resolved = resolveSortTarget(roomName) def effective = effectiveSortTarget(roomName) def statusBadge if (!included) { statusBadge = badge("skipped", "#757575") } else if (!resolved) { statusBadge = badge("will skip", "#c62828") } else { statusBadge = badge("will sort", "#2e7d32") } input sortRoomSettingName(roomName), "bool", title: "Sort devices matched as ${roomName}", defaultValue: true, submitOnChange: true, width: 12, newLineAfter: true def notes = [] if (suggestedId && resolved && resolved.roomId.toString() != suggestedId.toString()) { notes << "Suggested: ${escapeHtml(roomName)} → sorting into ${escapeHtml(resolved.roomName)}" } else if (!suggestedId && effective == sortTargetSkipValue()) { notes << "Room not created yet — create in Step 1, or pick another target room" } else if (!suggestedId && resolved) { notes << "Suggested room not on hub; sorting into ${escapeHtml(resolved.roomName)}" } def lines = devices.collect { d -> def flag = d.ambiguous ? " ⚠ ambiguous" : "" if (d.matchedAlias == "(inherited)") flag += " (inherited)" "• ${escapeHtml(d.deviceName)}${flag}" }.join("
") def noteHtml = notes ? "
${notes.join('
')}" : "" paragraph "${statusBadge} ${devices.size()} device(s)${noteHtml}
${lines}", width: 6 input sortTargetSettingName(roomName), "enum", title: "Target room for ${roomName}", options: roomOpts, defaultValue: defaultTarget, required: false, submitOnChange: true, width: 6, newLineAfter: true } } } section("Device exclusions") { paragraph "Select any matched devices to leave unassigned during this sort." input "excludedDeviceIds", "enum", title: "Exclude these matched devices from sorting", options: excludeOptions, multiple: true, required: false } section("Leftovers") { href "leftoversPage", title: "Assign unmatched devices", description: "Bulk-assign devices that did not auto-match to a room" } section("Sort") { if (state.applyInProgress) { paragraph calloutBox( "Sort in progress… ${escapeHtml(state.applyProgress ?: '')}
This page refreshes automatically. Open Results to watch the log or cancel.", "info" ) href "resultsPage", title: "View progress / Cancel" } else { def progress = state.applyProgress?.toString() ?: "" if (progress) { def lower = progress.toLowerCase() if (lower.contains("done") || lower.contains("cancel") || lower.contains("timed out") || lower.contains("fail")) { def kind = lower.contains("done") ? "success" : (lower.contains("cancel") || lower.contains("timed out") || lower.contains("fail") ? "warning" : "info") paragraph calloutBox("${escapeHtml(progress)}", kind) } } if (!acknowledgedRisk) { paragraph calloutBox("Acknowledge the safety checkbox on the main page before sorting.", "danger") } else { paragraph calloutBox( "Sort Devices will assign about ${willSortCount} device(s) into ${includedRooms.size()} room(s). Already-assigned devices are never moved. Writes use undocumented hub endpoints.", "warning" ) input "btnSortDevices", "button", title: "Sort Devices" } href "resultsPage", title: "Results / Undo", description: "View the last sort log or undo it" } } } } def leftoversPage() { refreshScanIntoState() def unmatched = (state.matchPreview ?: []).findAll { !it.proposedRoomKey } def rooms = (state.hubRooms ?: []) + (state.roomPlan ?: []).findAll { it.hubRoomId }.collect { [id: it.hubRoomId, name: it.canonicalName] } rooms = rooms.unique { it.id }.sort { it.name?.toLowerCase() } def roomOpts = rooms.collectEntries { [(it.id.toString()): it.name] } def deviceOpts = unmatched.collectEntries { [(it.deviceId.toString()): it.deviceName] } def manual = state.manualAssignments ?: [:] dynamicPage(name: "leftoversPage", title: "Leftovers — manual assignment", nextPage: "previewPage") { section("Overview") { paragraph calloutBox( "${unmatched.size()} unmatched device(s) · ${manual.size()} manual assignment(s) queued", "info" ) paragraph "Pick a target room, select devices, then tap Add to plan. Assignments are applied when you Sort on Step 2." } section("Current manual assignments") { if (!manual) { paragraph calloutBox("No manual assignments yet. Use the section below to add devices to a room.", "info") } else { def lines = manual.collect { deviceId, roomId -> def dn = unmatched.find { it.deviceId.toString() == deviceId.toString() }?.deviceName ?: "Device ${deviceId}" def rn = rooms.find { it.id.toString() == roomId.toString() }?.name ?: "Room ${roomId}" "• ${escapeHtml(dn)} → ${escapeHtml(rn)}" }.join("
") paragraph lines input "btnClearManual", "button", title: "Clear manual assignments" } } section("Add devices to a room") { input "leftoverRoomId", "enum", title: "Target room", options: roomOpts, required: false, submitOnChange: true input "leftoverDeviceIds", "enum", title: "Unmatched devices", options: deviceOpts, multiple: true, required: false input "btnAddLeftovers", "button", title: "Add to plan" } } } def resultsPage() { clearStaleApplyLock() def refresh = state.applyInProgress ? 5 : 0 dynamicPage(name: "resultsPage", title: "Results", refreshInterval: refresh, nextPage: "mainPage") { section("Status") { if (state.applyInProgress) { paragraph calloutBox("Sort in progress… ${escapeHtml(state.applyProgress ?: '')}", "info") input "btnCancelSort", "button", title: "Cancel sort" } else { def progress = state.applyProgress ?: "Idle — no sort running." def lower = progress.toString().toLowerCase() def kind = lower.contains("done") ? "success" : (lower.contains("cancel") || lower.contains("timed out") || lower.contains("fail") ? "warning" : "info") paragraph calloutBox(escapeHtml(progress.toString()), kind) } } section("Last run log") { def logText = (state.lastRunLog ?: []).join("\n") if (logText) { paragraph calloutBox("
${escapeHtml(logText)}
", "info") } else { paragraph calloutBox("No run yet. After you sort devices, the log appears here.", "info") } } section("Undo") { if (state.lastRun?.undo) { paragraph calloutBox( "Undo restores each device's previous room from the last sort. Optionally delete rooms created in that run if they are now empty.", "warning" ) input "btnUndoSort", "button", title: "Undo last sort" input "undoDeleteCreatedRooms", "bool", title: "Also delete empty rooms created in that run", defaultValue: true } else { paragraph calloutBox("Nothing to undo.", "info") } if (state.lastUndoLog) { paragraph calloutBox("Last undo result
${escapeHtml(state.lastUndoLog.toString())}
", "info") } } } } // --------------------------------------------------------------------------- // Buttons // --------------------------------------------------------------------------- def appButtonHandler(btn) { def name = normalizeAppButton(btn) log.info "Auto Room Sorter button: ${name}" if (name == "btnSaveAliases" || name == "btnSaveAliasesBottom") { handleSaveAliases() return } if (name.startsWith("btnClearAlias_")) { handleClearAliases(name.substring("btnClearAlias_".length())) return } switch (name) { case "btnAddRoom": handleAddRoomButton() break case "btnCreateRooms": handleCreateRooms() break case "btnSortDevices": handleSortDevices() break case "btnAddLeftovers": handleAddLeftovers() break case "btnClearManual": state.manualAssignments = [:] break case "btnCancelSort": cancelApply() break case "btnUndoSort": handleUndo() break } } def updated() { applyRoomPageEdits() applySortRoomIncludes() applySortRoomTargets() if (acknowledgedRisk) state.acknowledgedRisk = true logDebug "updated()" } def installed() { log.info "Auto Room Sorter installed" } // --------------------------------------------------------------------------- // Scan / plan // --------------------------------------------------------------------------- def refreshScanIntoState() { applyRoomPageEdits() def rooms = fetchHubRooms() def allDevices = fetchAllDevices() def devices = applyDeviceFilters(allDevices) state.deviceSnapshot = devices.collect { [ id: it.id, name: it.name, roomId: it.roomId, roomName: it.roomName, isVirtual: it.isVirtual, depth: it.depth, parentId: it.parentId ] } // Keep unfiltered parent lookup map for inheritance (parents may themselves be filtered) def devicesById = [:] allDevices.each { d -> devicesById[d.id?.toString()] = d } state.hubRooms = rooms def seeded = buildSeededRoomTargets(rooms) def plan = mergePlanWithDetections(devices, seeded, rooms) state.roomPlan = plan def matchPreview = [] devices.findAll { !it.roomId }.each { device -> def result = matchDeviceToPlan(device.name, plan) // Unchecked New rooms are excluded from create and from auto-sort proposals if (result) { def planRoom = plan.find { normalizeLabel(it.canonicalName) == normalizeLabel(result.canonicalName) } if (planRoom && planRoom.include == false && !planRoom.hubRoomId) { result = null } } if (!result) { result = inheritRoomFromParent(device, devicesById, plan, rooms) } matchPreview << [ deviceId: device.id, deviceName: device.name, proposedRoomKey: result?.key, proposedRoomName: result?.canonicalName, proposedRoomId: result?.hubRoomId, ambiguous: !!result?.ambiguous, matchedAlias: result?.matchedAlias ] } // Apply manual leftovers on top (state.manualAssignments ?: [:]).each { deviceId, roomId -> def row = matchPreview.find { it.deviceId.toString() == deviceId.toString() } def room = rooms.find { it.id.toString() == roomId.toString() } ?: plan.find { it.hubRoomId?.toString() == roomId.toString() } if (row && room) { row.proposedRoomKey = room.name ?: room.canonicalName row.proposedRoomName = room.name ?: room.canonicalName row.proposedRoomId = room.id ?: room.hubRoomId row.ambiguous = false row.matchedAlias = "(manual)" } } state.matchPreview = matchPreview def proposed = matchPreview.findAll { it.proposedRoomKey } logDebug "Scan refresh: ${rooms.size()} rooms, ${devices.size()} devices, ${plan.size()} plan rooms, ${proposed.size()} proposed matches" } Map inheritRoomFromParent(device, Map devicesById, List plan, List rooms) { if (inheritParentRoom == false) return null def parentId = device?.parentId if (!parentId) return null def parent = devicesById[parentId.toString()] if (!parent?.roomId) return null def roomName = (parent.roomName ?: "").toString().trim() def planRoom = plan.find { it.hubRoomId?.toString() == parent.roomId.toString() } if (!planRoom && roomName) { planRoom = plan.find { normalizeLabel(it.canonicalName) == normalizeLabel(roomName) } } if (!roomName) { roomName = planRoom?.canonicalName ?: (rooms.find { it.id?.toString() == parent.roomId.toString() }?.name ?: "") } if (!roomName) return null [ key: planRoom?.key ?: roomName, canonicalName: planRoom?.canonicalName ?: roomName, hubRoomId: (planRoom?.hubRoomId ?: parent.roomId) as Long, matchedAlias: "(inherited)", ambiguous: false ] } def applyDeviceFilters(devices) { devices.findAll { d -> if (!(d.name ?: "").toString().trim()) return false if (excludeChildren && (d.depth ?: 0) > 0) return false if (excludeVirtual && d.isVirtual) return false true } } def buildSeededRoomTargets(hubRooms) { def catalog = roomCatalog() hubRooms.collect { hr -> def canon = hr.name def aliases = [canon] as LinkedHashSet // Inherit catalog aliases when names normalize equal catalog.each { catName, catAliases -> if (normalizeLabel(catName) == normalizeLabel(canon)) { catAliases.each { aliases << it } } } [ key: canon, canonicalName: canon, aliases: aliases as List, hubRoomId: hr.id as Long, fromHub: true ] } } def mergePlanWithDetections(devices, seeded, hubRooms) { def catalog = roomCatalog() def previous = (state.roomPlan ?: []).collectEntries { [(normalizeLabel(it.canonicalName)): it] } // Match targets: seeded hub rooms (with inherited catalog aliases) + catalog rooms not already seeded def targets = [] def seen = [] as LinkedHashSet seeded.each { s -> def nkey = normalizeLabel(s.canonicalName) seen << nkey targets << [ key: s.canonicalName, canonicalName: s.canonicalName, aliases: aliasesFromOverride(s.canonicalName) ?: ((s.aliases ?: [s.canonicalName]) as List), hubRoomId: s.hubRoomId, fromHub: true, matchCount: 0 ] } catalog.each { name, aliases -> def nkey = normalizeLabel(name) if (seen.contains(nkey)) return seen << nkey targets << [ key: name, canonicalName: name, aliases: aliasesFromOverride(name) ?: (([name] + aliases) as List), hubRoomId: null, fromHub: false, matchCount: 0 ] } // Count matches from unassigned devices def byKey = targets.collectEntries { [(normalizeLabel(it.canonicalName)): it] } devices.findAll { !it.roomId }.each { device -> def result = matchLabelAgainstTargets(device.name, targets) if (!result) return def entry = byKey[normalizeLabel(result.canonicalName)] if (entry) entry.matchCount = (entry.matchCount ?: 0) + 1 } // Build plan: detected rooms (matchCount > 0) + prior user-added rooms def plan = [] byKey.values().each { entry -> def nkey = normalizeLabel(entry.canonicalName) def prev = previous[nkey] def fromOverride = aliasesFromOverride(entry.key ?: entry.canonicalName) if (fromOverride) entry.aliases = fromOverride else if (prev?.aliases) entry.aliases = prev.aliases if (prev?.include != null) entry.include = prev.include if (prev?.canonicalName && !entry.hubRoomId) { entry.canonicalName = prev.canonicalName entry.userAdded = prev.userAdded } if (isUserAdded(prev) || isRememberedUserAdded(nkey)) entry.userAdded = true if ((entry.matchCount ?: 0) > 0 || entry.userAdded) { def existing = existingRoomWithName(entry.canonicalName, hubRooms) if (existing) entry.hubRoomId = existing.id as Long plan << entry } } // Preserve user-added rooms that are not in catalog/seed targets previous.each { nkey, prev -> if (!isUserAdded(prev) && !isRememberedUserAdded(nkey)) return if (plan.find { normalizeLabel(it.canonicalName) == nkey }) return def existing = existingRoomWithName(prev.canonicalName, hubRooms) plan << [ key: prev.canonicalName, canonicalName: prev.canonicalName, aliases: aliasesFromOverride(prev) ?: (prev.aliases ?: [prev.canonicalName]), hubRoomId: existing?.id as Long, fromHub: !!existing, matchCount: 0, userAdded: true, include: prev.include != false ] } mergeUserAddedIntoPlan(plan, hubRooms) plan.sort { a, b -> (a.canonicalName ?: "").toLowerCase() <=> (b.canonicalName ?: "").toLowerCase() } } String roomNameSettingName(Object roomOrName) { "roomName_${aliasButtonSuffix(roomOrName)}".toString() } String roomIncludeSettingName(Object roomOrName) { "roomInclude_${aliasButtonSuffix(roomOrName)}".toString() } boolean isUserAdded(room) { def v = room?.userAdded v == true || v == "true" || v == 1 || v == "1" } boolean isRememberedUserAdded(String nkey) { loadUserAddedRooms().any { normalizeLabel(it.canonicalName) == nkey } } Map copyRoomEntry(entry) { def copy = new LinkedHashMap(entry) if (copy.aliases instanceof Collection) { copy.aliases = copy.aliases.collect { it } } copy } List loadUserAddedRooms() { def list = state.userAddedRooms if (list instanceof Collection && list) return list as List try { def fromAtomic = atomicState.userAddedRooms if (fromAtomic instanceof Collection && fromAtomic) return fromAtomic as List } catch (Exception ignored) { } [] } def rememberUserAddedRoom(Map room) { def copy = copyRoomEntry(room) copy.userAdded = true def nkey = normalizeLabel(copy.canonicalName) def list = loadUserAddedRooms().findAll { normalizeLabel(it.canonicalName) != nkey } list = new ArrayList(list) list.add(copy) state.userAddedRooms = list try { atomicState.userAddedRooms = list } catch (Exception ignored) { } } def mergeUserAddedIntoPlan(List plan, List hubRooms) { loadUserAddedRooms().each { added -> def nkey = normalizeLabel(added.canonicalName) def existing = plan.find { normalizeLabel(it.canonicalName) == nkey } def hub = existingRoomWithName(added.canonicalName, hubRooms) if (existing) { existing.userAdded = true if (hub) { existing.hubRoomId = hub.id as Long existing.fromHub = true } } else { def copy = copyRoomEntry(added) copy.userAdded = true copy.include = copy.include != false if (hub) { copy.hubRoomId = hub.id as Long copy.fromHub = true } plan << copy } } } /** Plain String key — Hubitat settings are Java maps; GString keys miss saved values. */ String settingKey(String prefix, idx) { "${prefix}_${idx}".toString() } String roomOverrideKey(Object roomOrName) { def raw = roomOrName instanceof Map ? (roomOrName.key ?: roomOrName.canonicalName) : roomOrName normalizeLabel(raw?.toString()) } String roomAliasSettingName(Object roomOrName) { "roomAlias_${aliasButtonSuffix(roomOrName)}".toString() } String aliasButtonSuffix(Object roomOrName) { roomOverrideKey(roomOrName).replaceAll("[^a-z0-9]+", "_") } String normalizeAppButton(btn) { if (btn == null) return "" if (btn instanceof Map) { def keys = btn.keySet() return keys ? keys.iterator().next().toString() : "" } def s = btn.toString() def dot = s.indexOf(".") if (dot > 0 && s.substring(0, dot).startsWith("btn")) return s.substring(0, dot) s } def setAliasFeedback(boolean ok, String msg) { state.lastAliasOk = ok state.lastAliasMessage = msg try { atomicState.lastAliasOk = ok atomicState.lastAliasMessage = msg } catch (Exception ignored) { } } def aliasFeedbackMessage() { def msg = state.lastAliasMessage if (msg) return msg.toString() try { def am = atomicState.lastAliasMessage if (am) return am.toString() } catch (Exception ignored) { } null } boolean aliasFeedbackOk() { def v = state.lastAliasOk if (v != null) return v != false try { return atomicState.lastAliasOk != false } catch (Exception ignored) { } true } def emitAliasFeedbackBanner() { def msg = aliasFeedbackMessage() if (!msg) return def ok = aliasFeedbackOk() def title = ok ? "✓ Aliases saved" : "✗ Aliases not saved" if (msg.toLowerCase().contains("cleared")) title = ok ? "✓ Aliases cleared" : "✗ Clear failed" paragraph calloutBox( "${title}
${escapeHtml(msg)}", ok ? "success" : "danger" ) } List aliasesFromOverride(Object roomOrName) { def raw = (state.aliasOverrides ?: [:])[roomOverrideKey(roomOrName)] if (raw == null) return null def parsed = splitAliases(raw) if (parsed) return parsed def name = roomOrName instanceof Map ? (roomOrName.canonicalName ?: roomOrName.key) : roomOrName name ? [name.toString()] : [] } def putAliasOverride(Object roomOrName, String aliasStr) { def overrides = new LinkedHashMap(state.aliasOverrides ?: [:]) overrides[roomOverrideKey(roomOrName)] = aliasStr state.aliasOverrides = overrides } def captureAliasOverrides() { def overrides = new LinkedHashMap(state.aliasOverrides ?: [:]) def skip = state.clearedAliasKey try { if (!skip) skip = atomicState.clearedAliasKey } catch (Exception ignored) { } (state.roomPlan ?: []).eachWithIndex { room, idx -> def nkey = roomOverrideKey(room) if (skip && nkey == skip) return def aliasStr = settings[roomAliasSettingName(room)] if (aliasStr == null) aliasStr = settings[settingKey("roomAliases", idx)] if (aliasStr != null) { overrides[nkey] = aliasStr.toString() } } state.aliasOverrides = overrides state.clearedAliasKey = null try { atomicState.clearedAliasKey = null } catch (Exception ignored) { } } def emitAliasInput(idx, room) { def key = roomAliasSettingName(room) def current = settings[key] if (current == null) current = settings[settingKey("roomAliases", idx)] if (current == null) current = (state.aliasOverrides ?: [:])[roomOverrideKey(room)] if (current == null) current = (room.aliases ?: []).join(", ") current = current.toString() // Never pass defaultValue: Hubitat re-applies it on Save and wipes typed aliases. if (settings[key] == null) { app.updateSetting(key, [type: "text", value: current]) } input key, "text", title: "Aliases (comma-separated)", required: false def clearBtn = ("btnClearAlias_" + aliasButtonSuffix(room)).toString() input clearBtn, "button", title: "Clear aliases" def warnings = validateAliasList((settings[key] ?: current).toString()) if (warnings) { paragraph calloutBox("Alias warnings: ${escapeHtml(warnings.join('; '))}", "warning") } } def handleSaveAliases() { try { applyRoomPageEdits() (state.roomPlan ?: []).each { room -> def raw = (state.aliasOverrides ?: [:])[roomOverrideKey(room)] if (raw == null) return try { app.updateSetting(roomAliasSettingName(room), [type: "text", value: raw.toString()]) } catch (Exception ignored) { } } def n = (state.roomPlan ?: []).size() def when = aliasFeedbackTime() setAliasFeedback(true, "Saved aliases for ${n} room(s) at ${when}.") log.info "Auto Room Sorter: saved aliases for ${n} room(s)" } catch (Exception e) { setAliasFeedback(false, "Save failed: ${e.message}") log.error "Auto Room Sorter: save aliases failed: ${e.message}" } } def handleClearAliases(String suffix) { def room = (state.roomPlan ?: []).find { aliasButtonSuffix(it) == suffix } if (!room) { setAliasFeedback(false, "Could not find that room to clear.") return } def nkey = roomOverrideKey(room) state.clearedAliasKey = nkey try { atomicState.clearedAliasKey = nkey } catch (Exception ignored) { } putAliasOverride(room, "") try { app.updateSetting(roomAliasSettingName(room), [type: "text", value: ""]) } catch (Exception ignored) { } room.aliases = [room.canonicalName].findAll { it } state.roomPlan = (state.roomPlan ?: []).collect { entry -> def copy = new LinkedHashMap(entry) if (copy.aliases instanceof Collection) { copy.aliases = copy.aliases.collect { it } } copy } setAliasFeedback(true, "Cleared aliases for ${room.canonicalName} at ${aliasFeedbackTime()}. Matching uses the room name only.") log.info "Auto Room Sorter: cleared aliases for ${room.canonicalName}" } String aliasFeedbackTime() { try { return new Date().format("h:mm:ss a") } catch (Exception ignored) { return new Date().toString() } } def applyRoomPageEdits() { captureAliasOverrides() def plan = state.roomPlan if (!plan) return plan.eachWithIndex { room, idx -> if (!room.hubRoomId) { def newName = settings[roomNameSettingName(room)] if (newName) room.canonicalName = newName.toString().trim() room.include = roomIncludeValue(idx, room) } else { room.include = true } def fromOverride = aliasesFromOverride(room) if (fromOverride) { room.aliases = fromOverride } } bindPlanRoomsToExistingHubRooms(plan, state.hubRooms ?: []) // Reassign copies so Hubitat persists nested alias/name edits in state state.roomPlan = plan.collect { entry -> def copy = new LinkedHashMap(entry) if (copy.aliases instanceof Collection) { copy.aliases = copy.aliases.collect { it } } copy } } /** Stable identity-based key is checked first; legacy index-based key is a one-time migration fallback. */ boolean roomIncludeValue(idx, room = null) { if (room != null) { def stable = settings[roomIncludeSettingName(room)] if (stable != null) return stable != false } def legacy = settings[settingKey("roomInclude", idx)] if (legacy != null) return legacy != false if (room?.include != null) return room.include != false true } def applySortRoomIncludes() { def order = state.sortRoomOrder ?: [] def map = (state.sortRoomInclude ?: [:]) as Map order.each { roomName -> def key = normalizeLabel(roomName) def settingName = sortRoomSettingName(roomName) def setting = settings[settingName] if (setting != null) { map[key] = setting != false } else if (!map.containsKey(key)) { map[key] = true } } state.sortRoomInclude = map } boolean sortRoomIncluded(String roomName) { if (!roomName) return false def key = normalizeLabel(roomName) def map = state.sortRoomInclude ?: [:] if (map.containsKey(key)) return map[key] != false def setting = settings[sortRoomSettingName(roomName)] if (setting != null) return setting != false true } String sortRoomSettingName(String roomName) { "sortRoom_" + normalizeLabel(roomName).replaceAll(" ", "_") } String sortTargetSettingName(String roomName) { "sortTarget_" + normalizeLabel(roomName).replaceAll(" ", "_") } /** Sentinel value for Stage 2 target dropdown when suggested room is not on the hub yet. */ String sortTargetSkipValue() { "__skip__" } List availableSortRooms() { def rooms = (state.hubRooms ?: []) + (state.roomPlan ?: []).findAll { it.hubRoomId }.collect { [id: it.hubRoomId, name: it.canonicalName] } rooms.unique { it.id }.sort { it.name?.toLowerCase() } } Map sortTargetRoomOptions() { def opts = [(sortTargetSkipValue()): "(Room not created yet - will skip)"] availableSortRooms().each { room -> opts[room.id.toString()] = room.name } opts } String suggestedRoomIdForGroup(String roomName, List devices = null) { def rows = devices if (rows == null) { rows = (state.matchPreview ?: []).findAll { it.proposedRoomKey && normalizeLabel(it.proposedRoomName) == normalizeLabel(roomName) } } def withId = rows?.find { it.proposedRoomId } if (withId?.proposedRoomId) return withId.proposedRoomId.toString() def planRoom = (state.roomPlan ?: []).find { normalizeLabel(it.canonicalName) == normalizeLabel(roomName) } planRoom?.hubRoomId?.toString() } String defaultSortTarget(String roomName, List devices = null) { suggestedRoomIdForGroup(roomName, devices) ?: sortTargetSkipValue() } def applySortRoomTargets() { def order = state.sortRoomOrder ?: [] def map = (state.sortRoomTarget ?: [:]) as Map def matches = (state.matchPreview ?: []).findAll { it.proposedRoomKey } def byRoom = matches.groupBy { it.proposedRoomName } order.each { roomName -> def key = normalizeLabel(roomName) def setting = settings[sortTargetSettingName(roomName)] if (setting != null) { map[key] = setting.toString() } else { map[key] = defaultSortTarget(roomName, byRoom[roomName] ?: []) } } state.sortRoomTarget = map } String effectiveSortTarget(String roomName) { if (!roomName) return sortTargetSkipValue() def key = normalizeLabel(roomName) def map = state.sortRoomTarget ?: [:] if (map.containsKey(key) && map[key] != null) return map[key].toString() def setting = settings[sortTargetSettingName(roomName)] if (setting != null) return setting.toString() defaultSortTarget(roomName) } /** Returns [roomId, roomName] for a real target, or null when skip / missing. */ Map resolveSortTarget(String roomName) { def target = effectiveSortTarget(roomName) if (!target || target == sortTargetSkipValue()) return null def room = availableSortRooms().find { it.id.toString() == target.toString() } if (room) return [roomId: room.id as Long, roomName: room.name] def hub = (state.hubRooms ?: []).find { it.id.toString() == target.toString() } if (hub) return [roomId: hub.id as Long, roomName: hub.name] null } String settingStr(String name) { def v = settings[name] v == null ? "" : v.toString().trim() } def clearAddRoomInputs() { ["addRoomName", "addRoomAliases"].each { n -> try { app.removeSetting(n) } catch (Exception ignored) { app.updateSetting(n, [type: "text", value: ""]) } } try { app.removeSetting("addFromCatalog") } catch (Exception ignored) { app.updateSetting("addFromCatalog", [type: "enum", value: "__none__"]) } } def handleAddRoomButton() { applyRoomPageEdits() def customName = settingStr("addRoomName") def catalogName = settingStr("addFromCatalog") if (catalogName == "__none__") catalogName = "" // Typed custom name wins if both are filled — a sticky catalog enum used to swallow custom adds def name = customName ?: catalogName if (!name) { state.lastAddRoomOk = false state.lastAddRoomMessage = "Enter a custom room name or pick from the catalog, then tap Add room to plan." return } def aliases = splitAliases(settings["addRoomAliases"]) def catalog = roomCatalog() aliases = ([name] + (catalog[name] ?: []) + aliases).unique() def hubRooms = (state.hubRooms ?: []) as List def existing = existingRoomWithName(name, hubRooms) def plan = new ArrayList(state.roomPlan ?: []) def entry = [ key: name, canonicalName: name, aliases: aliases, hubRoomId: existing?.id as Long, fromHub: !!existing, matchCount: 0, userAdded: true, include: true ] if (plan.find { normalizeLabel(it.canonicalName) == normalizeLabel(name) } || isRememberedUserAdded(normalizeLabel(name))) { state.lastAddRoomOk = false state.lastAddRoomMessage = "${name} is already in the plan." log.info "Auto Room Sorter: room already in plan: ${name}" return } plan.add(entry) state.roomPlan = plan.sort { a, b -> (a.canonicalName ?: "").toLowerCase() <=> (b.canonicalName ?: "").toLowerCase() } rememberUserAddedRoom(entry) putAliasOverride(name, aliases.join(", ")) try { app.updateSetting(roomAliasSettingName(name), [type: "text", value: aliases.join(", ")]) } catch (Exception ignored) { } try { // Guarantee the new room starts checked — never let it inherit a stale // value from whatever room previously occupied this list position. app.updateSetting(roomIncludeSettingName(name), [type: "bool", value: true]) } catch (Exception ignored) { } state.lastAddRoomOk = true state.lastAddRoomMessage = existing ? "Added ${name} to the plan (already on the hub; will reuse, not duplicate)." : "Added ${name} to the plan." log.info "Auto Room Sorter: added ${name} to the plan" clearAddRoomInputs() } def handleAddLeftovers() { if (!leftoverRoomId || !leftoverDeviceIds) return def manual = state.manualAssignments ?: [:] def ids = leftoverDeviceIds instanceof Collection ? leftoverDeviceIds : [leftoverDeviceIds] ids.each { id -> manual[id.toString()] = leftoverRoomId.toString() } state.manualAssignments = manual } // --------------------------------------------------------------------------- // Create / sort / undo // --------------------------------------------------------------------------- def handleCreateRooms() { if (!acknowledgedRisk) { state.lastCreateLog = "Refused: safety acknowledgment required." logWarn "Create rooms refused: safety acknowledgment required" return } if (!writeProbeOk()) { state.lastCreateLog = "Write probe failed. Aborting create." logError "Create rooms aborted: write probe failed" return } applyRoomPageEdits() def liveRooms = new ArrayList(fetchHubRooms() ?: []) state.hubRooms = liveRooms def plan = state.roomPlan ?: [] bindPlanRoomsToExistingHubRooms(plan, liveRooms) def lines = [] def createdIds = [] logInfo "Creating rooms from plan (${plan.size()} entries)" plan.each { room -> if (room.hubRoomId) { lines << "SKIP (exists): ${room.canonicalName} id=${room.hubRoomId}" return } if (room.include == false) { lines << "SKIP (unchecked): ${room.canonicalName}" return } def decision = resolveRoomCreate(room.canonicalName, liveRooms) if (decision.action == "reuse") { room.hubRoomId = decision.roomId as Long lines << "SKIP (exists): ${room.canonicalName} id=${decision.roomId}" logInfo "Reusing existing room ${room.canonicalName} id=${decision.roomId}" return } if (decision.action != "create") { lines << "SKIP (${decision.reason}): ${room.canonicalName}" logWarn "Skip creating ${room.canonicalName}: ${decision.reason}" return } try { def resp = createRoom(room.canonicalName) def newId = resp?.roomId if (newId) { room.hubRoomId = newId as Long if (resp?.reused) { lines << "SKIP (exists): ${room.canonicalName} id=${newId}" logInfo "Reusing existing room ${room.canonicalName} id=${newId}" liveRooms << [id: newId as Long, name: room.canonicalName] } else { lines << "CREATED: ${room.canonicalName} id=${newId}" logDebug "Created room ${room.canonicalName} id=${newId}" liveRooms << [id: newId as Long, name: room.canonicalName] createdIds << (newId as Long) } } else { lines << "FAILED: ${room.canonicalName} — response ${resp}" logError "Failed to create room ${room.canonicalName}: ${resp}" } } catch (Exception e) { lines << "ERROR: ${room.canonicalName} — ${e.message}" logError "Error creating room ${room.canonicalName}: ${e.message}" } } // Refresh IDs from hub def hubRooms = fetchHubRooms() state.hubRooms = hubRooms plan.each { room -> def existing = existingRoomWithName(room.canonicalName, hubRooms) if (existing) room.hubRoomId = existing.id as Long } state.roomPlan = plan state.lastCreateLog = lines.join("\n") state.lastCreatedRoomIds = createdIds logInfo "Create rooms finished: ${(state.lastCreatedRoomIds ?: []).size()} created" refreshScanIntoState() } def handleSortDevices() { if (!acknowledgedRisk) { state.lastRunLog = ["Refused: safety acknowledgment required."] logWarn "Sort refused: safety acknowledgment required" return } if (state.applyInProgress) { state.lastRunLog = ["Sort already in progress."] logWarn "Sort refused: already in progress" return } if (!writeProbeOk()) { state.lastRunLog = ["Write probe failed. Aborting sort."] logError "Sort aborted: write probe failed" return } refreshScanIntoState() def matches = (state.matchPreview ?: []).findAll { it.proposedRoomKey } state.sortRoomOrder = matches.groupBy { it.proposedRoomName }.keySet().sort { it?.toLowerCase() } applySortRoomIncludes() applySortRoomTargets() def excluded = (excludedDeviceIds ?: []).collect { it.toString() } as Set def queue = [] def undo = [] matches.each { row -> if (!sortRoomIncluded(row.proposedRoomName)) return if (excluded.contains(row.deviceId.toString())) return def resolved = resolveSortTarget(row.proposedRoomName) if (!resolved) { queue << [deviceId: row.deviceId, deviceName: row.deviceName, roomId: null, roomName: row.proposedRoomName, skip: "no room id"] return } def prior = (state.deviceSnapshot ?: []).find { it.id == row.deviceId } undo << [deviceId: row.deviceId, previousRoomId: prior?.roomId ?: 0] queue << [deviceId: row.deviceId, deviceName: row.deviceName, roomId: resolved.roomId as Long, roomName: resolved.roomName] } state.applyQueue = queue state.lastRunLog = [] state.lastRun = [ started: now(), undo: undo, createdRoomIds: state.lastCreatedRoomIds ?: [] ] state.applyInProgress = true state.applyStartedAt = now() state.applyProgress = "Queued ${queue.size()} device(s)" logInfo "Sort queued ${queue.size()} device(s)" runIn(1, "applyNextBatch") } def clearStaleApplyLock() { if (!state.applyInProgress) return if (!state.applyStartedAt) return if ((now() - (state.applyStartedAt as Long)) <= 5 * 60 * 1000) return logWarn "Apply lock stale on page load; clearing" unschedule("applyNextBatch") state.applyQueue = [] state.applyInProgress = false state.applyProgress = "Timed out / interrupted. Check Results log." def logLines = state.lastRunLog ?: [] logLines << "TIMED OUT / interrupted" state.lastRunLog = logLines } def finishApply(String progressMessage) { state.applyQueue = [] state.applyInProgress = false state.applyProgress = progressMessage logInfo progressMessage try { refreshScanIntoState() } catch (Exception e) { logWarn "Post-sort scan refresh failed: ${e.message}" } } def applyNextBatch() { if (!state.applyInProgress) return try { // Stale lock if (state.applyStartedAt && (now() - (state.applyStartedAt as Long)) > 5 * 60 * 1000) { logWarn "Apply lock stale; clearing" cancelApply() state.applyProgress = "Timed out / interrupted. Check Results log." return } def queue = (state.applyQueue ?: []) as List if (!queue) { finishApply("Done. ${(state.lastRunLog ?: []).size()} log line(s).") return } def logLines = state.lastRunLog ?: [] // Drain skip entries first while (queue && queue[0]?.skip) { def item = queue.remove(0) logLines << "SKIP ${item.deviceName}: ${item.skip}" logWarn "Skip ${item.deviceName}: ${item.skip}" } if (!queue) { state.lastRunLog = logLines finishApply("Done. ${logLines.size()} device(s) processed.") return } // Process one room per tick via bulk membership replace (merged with existing devices) def roomId = queue[0].roomId as Long def roomName = queue[0].roomName def batch = [] def remaining = [] queue.each { item -> if (!item.skip && item.roomId != null && (item.roomId as Long) == roomId) { batch << item } else { remaining << item } } state.applyQueue = remaining logDebug "Apply room ${roomName} (${roomId}): ${batch.size()} device(s); remaining ${remaining.size()}" def assigned = bulkAssignDevicesToRoom(roomId, roomName, batch) if (assigned) { batch.each { item -> logLines << "OK ${item.deviceName} → ${roomName} (${roomId})" logDebug "OK ${item.deviceName} → ${roomName} (${roomId})" } } else { logWarn "Bulk assign failed for ${roomName}; falling back to per-device setRoom" batch.each { item -> try { def resp = setDeviceRoom(item.deviceId, item.roomId) if (resp?.success) { logLines << "OK ${item.deviceName} → ${item.roomName} (${item.roomId})" logDebug "OK ${item.deviceName} → ${item.roomName} (${item.roomId})" } else { logLines << "FAIL ${item.deviceName} → ${item.roomName}: ${resp}" logError "Fail ${item.deviceName} → ${item.roomName}: ${resp}" } } catch (Exception e) { logLines << "ERROR ${item.deviceName}: ${e.message}" logError "Error assigning ${item.deviceName}: ${e.message}" } pauseExecution(50) } } state.lastRunLog = logLines state.applyProgress = "Processed ${logLines.size()} / approx remaining ${state.applyQueue.size()}" if (state.applyQueue) { runIn(1, "applyNextBatch") } else { finishApply("Done. ${logLines.size()} device(s) processed.") } } catch (Exception e) { logError "Sort batch failed: ${e.message}" def logLines = state.lastRunLog ?: [] logLines << "ERROR sort batch: ${e.message}" state.lastRunLog = logLines finishApply("Failed: ${e.message}. Check Results log.") } } def cancelApply() { unschedule("applyNextBatch") state.applyQueue = [] state.applyInProgress = false state.applyProgress = "Cancelled." def logLines = state.lastRunLog ?: [] logLines << "CANCELLED by user" state.lastRunLog = logLines logWarn "Sort cancelled" } def handleUndo() { def undo = state.lastRun?.undo if (!undo) { state.lastUndoLog = "Nothing to undo." logInfo "Undo: nothing to undo" return } if (!writeProbeOk()) { state.lastUndoLog = "Write probe failed." logError "Undo aborted: write probe failed" return } def lines = [] logInfo "Undo starting for ${undo.size()} device(s)" undo.each { entry -> try { def prior = entry.previousRoomId ?: 0 def resp = setDeviceRoom(entry.deviceId, prior as Long) if (resp?.success) { lines << "RESTORED device ${entry.deviceId} → room ${prior}" logDebug "Restored device ${entry.deviceId} → room ${prior}" } else { lines << "FAIL device ${entry.deviceId}: ${resp}" logError "Undo fail device ${entry.deviceId}: ${resp}" } } catch (Exception e) { lines << "ERROR device ${entry.deviceId}: ${e.message}" logError "Undo error device ${entry.deviceId}: ${e.message}" } pauseExecution(50) } if (undoDeleteCreatedRooms) { def created = state.lastRun?.createdRoomIds ?: [] def hubRooms = fetchHubRooms() def devices = fetchAllDevices() created.each { rid -> def still = hubRooms.find { it.id as Long == rid as Long } if (!still) { lines << "SKIP delete room ${rid}: already gone" return } def occupied = devices.any { (it.roomId as Long) == (rid as Long) } if (occupied) { lines << "SKIP delete room ${still.name}: not empty" } else { try { deleteRoom(rid) lines << "DELETED empty room ${still.name} (${rid})" logDebug "Deleted empty room ${still.name} (${rid})" } catch (Exception e) { lines << "ERROR delete room ${rid}: ${e.message}" logError "Error deleting room ${rid}: ${e.message}" } } } } state.lastUndoLog = lines.join("\n") state.lastRun = null logInfo "Undo finished: ${lines.size()} log line(s)" refreshScanIntoState() } // --------------------------------------------------------------------------- // Matching (pure) // --------------------------------------------------------------------------- String normalizeLabel(String raw) { if (raw == null) return "" def s = raw.toLowerCase() // Strip apostrophes / backticks so Saadya's → saadyas s = s.replaceAll(/[''`’]/, "") s = s.replaceAll(/[^a-z0-9]+/, " ") s = s.replaceAll(/\s+/, " ").trim() return s } List tokenizeLabel(String raw) { def n = normalizeLabel(raw) if (!n) return [] return n.split(" ") as List } List splitAliases(Object raw) { if (raw == null) return [] raw.toString().split(/\s*,\s*/).collect { it.trim() }.findAll { it } } Map matchDeviceToPlan(String label, List plan) { def targets = plan.collect { [ key: it.key ?: it.canonicalName, canonicalName: it.canonicalName, aliases: (it.aliases ?: [it.canonicalName]) as List, hubRoomId: it.hubRoomId ] } matchLabelAgainstTargets(label, targets) } Map matchLabelAgainstTargets(String label, List targets) { def tokens = tokenizeLabel(label) if (!tokens) return null def candidates = [] targets.each { t -> (t.aliases ?: []).each { alias -> def aliasTrim = alias == null ? "" : alias.toString().trim() if (isAllowedShortAlias(aliasTrim)) { def idx = indexOfCapitalizedAbbrev(label, aliasTrim) if (idx < 0) return candidates << [ key: t.key ?: t.canonicalName, canonicalName: t.canonicalName, aliases: t.aliases, hubRoomId: t.hubRoomId, matchedAlias: aliasTrim, scoreTokens: 1, scoreChars: aliasTrim.size(), position: idx ] return } def aliasTokens = tokenizeLabel(alias) if (!aliasTokens) return def idx = indexOfTokenSequence(tokens, aliasTokens) if (idx >= 0) { candidates << [ key: t.key ?: t.canonicalName, canonicalName: t.canonicalName, aliases: t.aliases, hubRoomId: t.hubRoomId, matchedAlias: alias, scoreTokens: aliasTokens.size(), scoreChars: normalizeLabel(alias).replace(" ", "").size(), position: idx ] } } } if (!candidates) return null candidates.sort { a, b -> b.scoreTokens <=> a.scoreTokens ?: b.scoreChars <=> a.scoreChars ?: a.position <=> b.position } def best = candidates[0] def bestScore = [best.scoreTokens, best.scoreChars] def rivals = candidates.findAll { it.key != best.key && it.scoreTokens == bestScore[0] && it.scoreChars == bestScore[1] } best.ambiguous = !rivals.isEmpty() return best } int indexOfTokenSequence(List tokens, List aliasTokens) { if (!aliasTokens || aliasTokens.size() > tokens.size()) return -1 for (int i = 0; i <= tokens.size() - aliasTokens.size(); i++) { def ok = true for (int j = 0; j < aliasTokens.size(); j++) { if (tokens[i + j] != aliasTokens[j]) { ok = false break } } if (ok) return i } return -1 } /** Match uppercase abbrev as its own token followed by whitespace (e.g. "LR Light"). */ int indexOfCapitalizedAbbrev(String label, String abbrev) { if (!label || !abbrev) return -1 def re = ~/(?:^|[^A-Za-z0-9])(${java.util.regex.Pattern.quote(abbrev)})\s/ def m = (label =~ re) if (!m.find()) return -1 def prefix = label.substring(0, m.start(1)) return tokenizeLabel(prefix).size() } List validateAliasList(String aliasStr) { def warnings = [] splitAliases(aliasStr).each { alias -> def trimmed = alias.trim() if (!trimmed) return if (isAllowedShortAlias(trimmed)) return def n = normalizeLabel(trimmed) if (!n) return if (n.replace(" ", "").size() < 3) { if (n in ["lr", "dr"]) { warnings << "'${alias}' must be capitalized as ${n.toUpperCase()} (matches only as '${n.toUpperCase()} ' in labels)" } else { warnings << "'${alias}' is shorter than 3 characters" } } def toks = tokenizeLabel(alias) if (toks.size() == 1 && isBareQualifier(toks[0])) { warnings << "'${alias}' is a bare qualifier — pair it with a room noun (e.g. 'master bath')" } if (toks.size() == 1 && isDeniedWord(toks[0])) { warnings << "'${alias}' is too generic and should not be used as an alias" } } warnings } boolean isAllowedShortAlias(String alias) { alias in ["LR", "DR"] } List findAliasCollisions(List plan) { def map = [:] // normalized alias -> list of room names plan.each { room -> (room.aliases ?: []).each { alias -> def n = normalizeLabel(alias) if (!n) return map[n] = (map[n] ?: []) + [room.canonicalName] } } map.findAll { k, rooms -> rooms.unique().size() > 1 }.collect { k, rooms -> "'${k}' used by ${rooms.unique().join(' & ')}" } } boolean isBareQualifier(String tok) { tok in [ "master", "guest", "kids", "main", "upstairs", "downstairs", "front", "back", "side", "north", "south", "east", "west", "big", "little", "old", "new", "second", "upper", "lower" ] } boolean isDeniedWord(String tok) { tok in [ "room", "hall", "bar", "area", "zone", "light", "switch", "sensor", "home", "house", "floor" ] } // --------------------------------------------------------------------------- // HTTP layer // --------------------------------------------------------------------------- Map probeHubEndpoints() { try { def rooms = hubGetJson("/room/listRoomsJson") if (!(rooms instanceof List)) { def msg = "/room/listRoomsJson did not return a list" logError "Probe failed: ${msg}" return [ok: false, message: msg] } def devicesPayload = hubGetJson("/hub2/devicesList") if (!(devicesPayload instanceof Map) || devicesPayload.devices == null) { // fallback probe def alt = hubGetJson("/device/list/data") if (!(alt instanceof List)) { def msg = "Neither /hub2/devicesList nor /device/list/data responded as expected" logError "Probe failed: ${msg}" return [ok: false, message: msg] } def msg = "Rooms ${rooms.size()}, devices(list/data) ${alt.size()} (hub2 unavailable — children missing)" logWarn "Probe degraded: hub2/devicesList unavailable; using /device/list/data" return [ok: true, message: msg] } def flat = flattenHub2Devices(devicesPayload) def msg = "Firmware probe OK — ${rooms.size()} rooms, ${flat.size()} devices (incl. children)" logDebug msg return [ok: true, message: msg] } catch (Exception e) { logError "Probe failed: ${e.message}" return [ok: false, message: e.message] } } boolean writeProbeOk() { try { def devices = fetchAllDevices() def candidate = devices.find { !it.roomId } if (!candidate) { logWarn "Write probe skipped: no unassigned device available" return true } def resp = setDeviceRoom(candidate.id, 0L) if (resp?.success) { logDebug "Write probe OK for device ${candidate.id}" return true } logError "Write probe failed: ${resp}" return false } catch (Exception e) { logError "Write probe error: ${e.message}" return false } } List fetchHubRooms() { def rooms = hubGetJson("/room/listRoomsJson") if (!(rooms instanceof List)) throw new RuntimeException("Bad rooms payload") rooms.collect { [id: it.id as Long, name: it.name?.toString()] } } List fetchAllDevices() { try { def payload = hubGetJson("/hub2/devicesList") if (payload instanceof Map && payload.devices != null) { return flattenHub2Devices(payload) } } catch (Exception e) { logWarn "hub2/devicesList failed: ${e.message}; falling back to /device/list/data" } def list = hubGetJson("/device/list/data") if (!(list instanceof List)) throw new RuntimeException("Bad devices payload") list.collect { d -> def display = (d.label ?: d.displayName ?: "").toString().trim() [ id: d.id as Long, name: display, roomId: d.roomId ? (d.roomId as Long) : null, roomName: d.deviceRoomName ?: "", isVirtual: false, depth: 0, parentId: d.parentDeviceId ] }.findAll { it.name } } List flattenHub2Devices(Map payload) { def out = [] def walk walk = { nodes, depth, parentId -> (nodes ?: []).each { n -> def d = n.data ?: [:] // Skip pure room nodes if any appear if (d.type == "Room") { walk(n.children, depth, parentId) return } if (d.id != null) { out << [ id: d.id as Long, name: (d.name ?: "").toString(), roomId: d.roomId ? (d.roomId as Long) : null, roomName: d.roomName ?: "", isVirtual: !!d.isVirtual, depth: depth, parentId: parentId, type: d.type ] } walk(n.children, depth + 1, d.id as Long) } } walk(payload.devices, 0, null) out.findAll { (it.name ?: "").trim() } } /** Find a hub/plan room whose name matches after normalizeLabel (case, punctuation, apostrophes). */ Map existingRoomWithName(String name, List rooms) { def key = normalizeLabel(name) if (!key) return null (rooms ?: []).find { entry -> def entryName = (entry?.name ?: entry?.canonicalName)?.toString() normalizeLabel(entryName) == key } } def bindPlanRoomsToExistingHubRooms(List plan, List hubRooms) { (plan ?: []).each { room -> if (room.hubRoomId) return def hit = existingRoomWithName(room.canonicalName, hubRooms) def id = hit?.id ?: hit?.hubRoomId if (id != null) { room.hubRoomId = id as Long room.fromHub = true } } } /** Decide whether a name should be created, reused, or skipped. Never returns create when the name is taken. */ Map resolveRoomCreate(String name, List hubRooms) { def trimmed = (name ?: "").toString().trim() if (!trimmed) return [action: "skip", reason: "empty name"] def existing = existingRoomWithName(trimmed, hubRooms) if (existing) { def id = existing.id ?: existing.hubRoomId if (id != null) { return [action: "reuse", roomId: id as Long, name: (existing.name ?: existing.canonicalName)] } } [action: "create", name: trimmed] } List findRoomNameCollisions(List plan, List hubRooms = null) { def warnings = [] def hub = hubRooms ?: [] def byKey = [:] (plan ?: []).each { room -> def key = normalizeLabel(room.canonicalName) if (!key) return byKey[key] = (byKey[key] ?: []) + [room.canonicalName] } byKey.each { key, names -> if (names.size() > 1) { warnings << "Name '${names[0]}' is used ${names.size()} times in the plan — only one room will be created" } } (plan ?: []).findAll { !it.hubRoomId }.each { room -> def hit = existingRoomWithName(room.canonicalName, hub) if (hit) { warnings << "'${room.canonicalName}' already exists on the hub as '${hit.name}' — will reuse, not create a duplicate" } } warnings } Map createRoom(String name) { def live = fetchHubRooms() def decision = resolveRoomCreate(name, live) if (decision.action == "reuse") { logInfo "Not creating duplicate room '${name}'; reusing '${decision.name}' id=${decision.roomId}" return [roomId: decision.roomId as Long, reused: true] } if (decision.action != "create") { throw new RuntimeException("Cannot create room '${name}': ${decision.reason}") } hubPostJson("/room/save", [roomId: 0, name: decision.name, deviceIds: []]) } /** * Bulk-assign devices into a room via POST /room/save. * Hub replaces the room's full membership with deviceIds, so existing members must be merged in. * Returns true on success. */ boolean bulkAssignDevicesToRoom(Long roomId, String roomName, List batch) { if (!roomId || !batch) return false def newIds = batch.collect { it.deviceId as Long } try { def existing = currentRoomDeviceIds(roomId) def merged = [] as LinkedHashSet existing.each { merged << (it as Long) } newIds.each { merged << (it as Long) } def resp = updateRoomMembership(roomId, roomName, merged as List) if (resp == null) return false if (resp instanceof Map) { // create/update returns {roomId: N} on success; errors often include error key if (resp.error) { logError "Bulk assign ${roomName}: ${resp}" return false } def returned = resp.roomId if (returned != null && returned.toString() != roomId.toString() && returned.toString() != "0") { // unexpected id change — treat as failure logError "Bulk assign ${roomName}: unexpected roomId ${returned}" return false } } return true } catch (Exception e) { logError "Bulk assign ${roomName} failed: ${e.message}" return false } } Map updateRoomMembership(Long roomId, String roomName, List deviceIds) { hubPostJson("/room/save", [ roomId: roomId as Long, name: roomName, deviceIds: deviceIds.collect { it as Long } ]) } List currentRoomDeviceIds(Long roomId) { // Prefer platform API when available (includes deviceIds) try { def rooms = app.getRooms() if (rooms instanceof List) { def hit = rooms.find { it?.id?.toString() == roomId.toString() } if (hit?.deviceIds != null) { return (hit.deviceIds as List).collect { it as Long } } } } catch (Exception ignored) { // fall through } // Snapshot of devices currently in this room def fromSnapshot = (state.deviceSnapshot ?: []).findAll { it.roomId != null && it.roomId.toString() == roomId.toString() }.collect { it.id as Long } if (fromSnapshot) return fromSnapshot // Hub2 rooms tree try { def payload = hubGetJson("/hub2/roomsList") def nodes = payload?.roomNodes ?: [] def node = nodes.find { it?.data?.id?.toString() == roomId.toString() } if (node) { return (node.children ?: []).collect { it?.data?.id as Long }.findAll { it != null } } } catch (Exception e) { logWarn "Could not load room membership for ${roomId}: ${e.message}" } return [] } Map setDeviceRoom(deviceId, Long roomId) { def resp = hubGetJson("/device/setRoom", [deviceId: deviceId.toString(), roomId: roomId.toString()]) if (resp instanceof Map) return resp return [success: resp == true || resp == "true", raw: resp] } def deleteRoom(roomId) { hubGetRaw("/room/delete/${roomId}", null) } def hubGetJson(String path, Map query = null) { def text = hubGetRaw(path, query) if (text == null || text.toString().trim().isEmpty()) return null def trimmed = text.toString().trim() if (trimmed == "true" || trimmed == "false") return trimmed == "true" return new groovy.json.JsonSlurper().parseText(trimmed) } String hubGetRaw(String path, Map query = null) { ensureHubCookie() def params = [ uri: "http://127.0.0.1:8080", path: path, contentType: "application/json", textParser: true, headers: [:] ] if (query) params.query = query if (state.cookie) params.headers.Cookie = state.cookie def body = null try { logDebug "GET ${path}${query ? " ${query}" : ""}" httpGet(params) { resp -> body = resp.data?.text ?: resp.data } } catch (Exception e) { logError "GET ${path} failed: ${e.message}" throw e } return body?.toString() } Map hubPostJson(String path, Map bodyMap) { ensureHubCookie() def json = new groovy.json.JsonBuilder(bodyMap).toString() def params = [ uri: "http://127.0.0.1:8080", path: path, contentType: "application/json", requestContentType: "application/json", body: json, headers: [:] ] if (state.cookie) params.headers.Cookie = state.cookie def result = null try { logDebug "POST ${path}" httpPost(params) { resp -> def text = resp.data?.text ?: resp.data if (text instanceof Map) { result = text } else { result = new groovy.json.JsonSlurper().parseText(text.toString()) } } } catch (Exception e) { logError "POST ${path} failed: ${e.message}" throw e } return result as Map } def ensureHubCookie() { if (!hubSecurity) { state.cookie = null return } try { logDebug "POST /login (hub security)" httpPost([ uri: "http://127.0.0.1:8080", path: "/login", query: [loginRedirect: "/"], body: [username: hubUsername, password: hubPassword, submit: "Login"] ]) { resp -> state.cookie = resp?.headers?.'Set-Cookie'?.split(';')?.getAt(0) } if (!state.cookie) { logWarn "Hub login did not return a session cookie" } } catch (Exception e) { logError "Hub login failed: ${e.message}" throw e } } String escapeHtml(String s) { if (s == null) return "" s.replace("&", "&").replace("<", "<").replace(">", ">") } String badge(String text, String color) { "${escapeHtml(text)}" } String calloutBox(String html, String kind) { def styles = [ info: [bg: "#e8f4fd", border: "#90caf9", color: "#0d47a1"], success: [bg: "#e8f5e9", border: "#81c784", color: "#1b5e20"], warning: [bg: "#fff8e1", border: "#ffcc80", color: "#e65100"], danger: [bg: "#ffebee", border: "#ef9a9a", color: "#b71c1c"] ] def s = styles[kind] ?: styles.info "
${html}
" } def logError(msg) { log.error "${msg}" } def logWarn(msg) { log.warn "${msg}" } def logInfo(msg) { if (logEnable) log.info "${msg}" } def logDebug(msg) { if (logEnable) log.debug "${msg}" } // --------------------------------------------------------------------------- // Room catalog // --------------------------------------------------------------------------- Map roomCatalog() { [ // Bedrooms "Master Bedroom": ["master bedroom", "master bed", "masterbedroom", "primary bedroom", "primary bed"], "Bedroom": ["bedroom", "bed room"], "Guest Bedroom": ["guest bedroom", "guest bed", "guest room"], "Kids Bedroom": ["kids bedroom", "kids room", "children bedroom", "childrens bedroom"], "Nursery": ["nursery"], "Boy Bedroom": ["boy bedroom", "boys bedroom", "boy room"], "Girl Bedroom": ["girl bedroom", "girls bedroom", "girl room"], // Bathrooms "Master Bathroom": ["master bathroom", "master bath", "masterbathroom", "ensuite", "en suite", "en-suite"], "Bathroom": ["bathroom", "bath room"], "Guest Bathroom": ["guest bathroom", "guest bath"], "Powder Room": ["powder room", "powder", "half bath", "halfbath", "powderroom"], "Kids Bathroom": ["kids bathroom", "kids bath"], "Jack and Jill Bathroom": ["jack and jill", "jack & jill"], // Living / family "Living Room": ["living room", "livingroom", "living rm", "LR"], "Family Room": ["family room", "familyroom"], "Great Room": ["great room", "greatroom"], "Sitting Room": ["sitting room"], "Den": ["den"], "Lounge": ["lounge"], "TV Room": ["tv room", "media room", "theater", "theatre", "home theater", "home theatre"], "Playroom": ["playroom", "play room", "game room", "gamesroom"], // Kitchen / dining "Kitchen": ["kitchen", "breakfast"], "Dining Room": ["dining room", "diningroom", "dining", "DR"], "Breakfast Nook": ["breakfast nook", "breakfast room", "nook"], "Pantry": ["pantry"], "Butler Pantry": ["butler pantry", "butlers pantry"], // Work "Office": ["office", "home office"], "Study": ["study"], "Library": ["library"], "Studio": ["studio"], "Workshop": ["workshop", "work shop"], // Utility "Laundry Room": ["laundry room", "laundryroom", "laundry", "utility room"], "Mudroom": ["mudroom", "mud room"], "Garage": ["garage"], "Attic": ["attic"], "Basement": ["basement", "cellar"], "Crawlspace": ["crawlspace", "crawl space"], "Boiler Room": ["boiler room", "furnace room", "mechanical room"], "Server Room": ["server room", "network closet"], "Closet": ["closet"], "Storage Room": ["storage room", "storeroom"], // Circulation "Foyer": ["foyer", "entry", "entryway", "entrance"], "Hallway": ["hallway", "hall way"], "Hallways": ["hallways"], "Stairs": ["stairs", "stairway", "staircase"], "Landing": ["landing"], "Corridor": ["corridor"], // Outdoor "Outside": ["outside", "outdoor", "outdoors", "exterior"], "Front Yard": ["front yard", "frontyard"], "Backyard": ["backyard", "back yard", "rear yard"], "Front Porch": ["front porch", "porch"], "Back Porch": ["back porch"], "Deck": ["deck"], "Patio": ["patio"], "Balcony": ["balcony"], "Driveway": ["driveway"], "Garden": ["garden"], "Pool": ["pool", "pool area"], "Hot Tub": ["hot tub", "spa"], "Shed": ["shed"], "Greenhouse": ["greenhouse"], "Roof": ["roof", "rooftop"], // Recreation / specialty "Gym": ["gym", "exercise room", "workout room", "fitness room"], "Sauna": ["sauna"], "Wine Cellar": ["wine cellar", "wine room"], "Home Bar": ["home bar"], "Craft Room": ["craft room", "hobby room"], "Music Room": ["music room"], "Guest House": ["guest house", "guesthouse", "adu"], "Apartment": ["apartment", "in-law suite", "inlaw suite"], "Loft": ["loft"], "Sunroom": ["sunroom", "sun room", "solarium"], "Conservatory": ["conservatory"] // Intentionally omit bare Upstairs/Downstairs/Nth Floor — too many false positives // (e.g. "Downstairs Lights"). Users can add them as custom rooms if needed. ] }