Very frustrating issue with Groovy code

Removing a setting or preference from code does not remove it from the database unless the developer explicitly codes and executes the removal. While not obvious the state/setting variable will show up on the Settings page (among others).

8 Likes

I finally had time to revisit this issue. Perhaps I don't understand the last 2 postings.

What I meant by a "bug" is that, while I'm editing app code within Hubitat (since there are rudimentary editing features there), I had expected that Hubitat would only act on the text actually displayed in the "Apps code" edit window. I didn't expect that Hubitat would keep parts of prior versions and behave as those retained parts were STILL part of the apps code that I'm editing. That can (and did) badly trip up a newby to Groovy.

In short, inadvertently having an input named "schedule" and including a schedule function ought to throw an error, but even if that doesn't happen, then renaming the input (e.g., "schedule" to "runTime") OUGHT to rename the input in the database mentioned by @thebearmay since there is apparently no tool set exposed to users to do that chore.

And the change to the title of this thread seems gratuitous and disingenuous. The problem has NOTHING to do with ChatGPT, it might well have occurred if I was simply plodding through my 2nd Groovy app using just the documentation. Accordingly, I've changed the title to something more neutral than the swipe at ChatGPT.

How do you know that the input wasn't renamed?

My apologies, no offense was intended.

Your original thread title describing this issue as a bug didn’t seem like an accurate characterization (based on the information available so far).

And you referred to ChatGPT writing “many iterations” of the code, which apparently included making changes that you were unaware of?

Perhaps I misunderstood, as a non-developer myself. Regardless, I do hope you get to the root of this issue.

2 Likes

@marktheknife - @John_Land is right to say that calling an input "schedule" is not something that only ChatGPT could do, it could just as easily be something a developer could do themselves without realising the consequences, or being able to easily understand the error that is returned. While not as experienced as some of the dev's in this topic, I at least would not have known or realised the significance of the use of the term schedule, albeit I may have avoided such a common term out of habit more than knowledge of the language or platform.

While I understand the premise for adjusting topic titles is (in part) to try and portray a more accurate representation of what some topics may actually be about, I think some at least could benefit from the inclusion of an explanation in the form of a comment so that people can assess the reasons behind the change, including the OP. I am largely in support of most title adjustments, but have also felt some could benefit from an explanation. I do, however, appreciate you offering an apology.

On the flip side... And by now I am starting to sound a little self-righteous.... @John_Land - to look at it from the other side... The use of the term bug is really a sticking point... (for some) In my line of work it can carry certain connotations about the quality of an IT system, which I feel is largely what people like Mark and others would respond to in this situation (including me). This is not so much a bug.. well this is where I struggle to explain the reasons.... It is hard to detect the removal of an input.... Maybe others can chime in with a better explanation.

3 Likes

Running the code does create the new preference, in your case 'runTime', but as I said above changing the code doesn't directly affect what is already stored in the database - the code editor has no connection to the database other than storing and retrieving the code. If the variable in question was in fact a run time variable, i.e. one not stored in the database, then renaming it in code renames it at runtime; changing the name of a preference or state variable (ones that are stored in the database) creates a new database entry with the new name (at run time) but since it is at run time, and not compile, it has no knowledge of the previous one. This actually works the same as every other editor I can think of, i.e. if you put it into the database it stays there until you remove it.

8 Likes

:point_up:
What he said.... :slight_smile:

2 Likes

This is, in general, not true (of any programming language). Programs have state (data) and inputs separate from the code.

Things are even trickier with dynamic languages like Groovy, which try to infer stuff all the time, the mechanics of such inferences being mostly obscure if not opaque to newcomers.

Your problem did have something to do with ChatGPT because 1) you're learning (great!) and 2) you didn't write all the code and therefore could not possibly master all its side effects (a common issue with AI generated code).

Glad you got to the root cause.

4 Likes

In case anyone is interested in digging deeper into the details, here's a conversation with Claude. Keep in mind, as always with gen AI, some of this is BS (some of it I called it on to get better followup answers).

Discussion with Claude on dynamic properties

Using the Groovy language on Hubitat Elevation. There is a preferences section in the app definition. This preferences section appears to be a Groovy closure in reality. an "input" statement appears to inject a property into the app class. Can you explain in better, clearer detail how this works ?

In Hubitat Elevation, the preferences section is indeed a Groovy closure, and it works through a Domain Specific Language (DSL) pattern that Hubitat implements. Here's how it actually works under the hood:

The DSL Pattern

The preferences closure is executed in a special context where Hubitat has defined methods like input, section, page, etc. When you write:

groovy

preferences {
    input "mySwitch", "capability.switch", title: "Select a switch"
    input "myNumber", "number", title: "Enter a number", defaultValue: 5
}

How input Creates Properties

The input statement doesn't directly inject properties into your app class in the traditional Java/Groovy sense. Instead, it:

  1. Registers the input definition with Hubitat's framework
  2. Creates a settings entry that gets populated when users configure the app
  3. Makes the value accessible through the settings map

Accessing Input Values

You don't access these as direct properties. Instead, you access them through the settings object:

groovy

def myMethod() {
    // Access the switch input value
    def selectedSwitch = settings.mySwitch
    
    // Access the number input value  
    def userNumber = settings.myNumber
    
    // Use them
    selectedSwitch.on()
    log.debug "User entered: ${userNumber}"
}

Behind the Scenes

What's happening is roughly equivalent to:

  1. During app installation/configuration, Hubitat processes the preferences closure
  2. It builds a configuration UI based on your input statements
  3. User selections are stored in a settings map
  4. Your app code accesses these via settings.inputName

Dynamic Property Access

Groovy's dynamic nature means you can also access settings using:

groovy

// These are equivalent:
settings.mySwitch
settings["mySwitch"]
this.settings.mySwitch

Important Notes

  • The input name becomes the key in the settings map
  • Values are automatically type-converted based on the input type
  • The settings object persists between app executions
  • Changes to preferences trigger app updates and re-execution of relevant methods

This DSL approach allows Hubitat to automatically generate the configuration UI while keeping your code clean and the framework handling all the UI generation and data binding complexity.

thank you, however, settings can be accessed directly from within app methods without prefixing with settings.

You're absolutely right! Thank you for the correction. In Hubitat Elevation apps, the input values can indeed be accessed directly as if they were properties of the app class itself.

Direct Property Access

So if you define:

groovy

preferences {
    input "mySwitch", "capability.switch", title: "Select a switch"
    input "myNumber", "number", title: "Enter a number", defaultValue: 5
}

You can access them directly in your methods:

groovy

def myMethod() {
    // Direct access - no 'settings.' prefix needed
    mySwitch.on()
    log.debug "User entered: ${myNumber}"
    
    if (mySwitch.currentValue("switch") == "on") {
        // do something
    }
}

How This Actually Works

This is implemented through Groovy's propertyMissing and/or methodMissing mechanisms, or more likely through AST (Abstract Syntax Tree) transformations. When Hubitat processes your app:

  1. The preferences closure is parsed to identify all input statements
  2. The framework dynamically adds these as accessible properties to your app class
  3. Behind the scenes, these likely delegate to the settings map, but the syntax appears as direct property access

Both Syntaxes Work

Interestingly, both access patterns work:

groovy

// Direct access (cleaner)
mySwitch.on()

// Via settings map (also valid)
settings.mySwitch.on()

This makes the code much cleaner and more intuitive - the inputs truly do behave like injected properties of your app class. The DSL not only generates the UI but actually extends your class with these dynamic properties at runtime.

Thanks for keeping me accurate on the Hubitat-specific behavior!

however it does mean there can be ambiguity between an app property, one of its inputs, a method name

Excellent point! You've identified a significant design consideration with this approach. The direct property access creates a potential namespace collision issue where:

Potential Conflicts

groovy

preferences {
    input "name", "text", title: "Device name"
    input "initialize", "bool", title: "Auto initialize"
    input "state", "text", title: "Current state"
}

def installed() {
    // Ambiguity issues:
    
    // Is this the input value or the app's actual name property?
    log.debug "Name is: ${name}"
    
    // Is this calling the initialize() method or accessing the input?
    initialize
    
    // Is this the app's state map or the input value?
    state.someValue = "test"
}

def initialize() {
    log.debug "Initialize method called"
}

Resolution Order

Groovy/Hubitat likely resolves these conflicts using a precedence order, probably something like:

  1. Local variables (highest priority)
  2. Method names
  3. Actual class properties
  4. Input properties (via propertyMissing)
  5. Settings map fallback (lowest priority)

Best Practices to Avoid Issues

groovy

preferences {
    // Use descriptive, unique names
    input "selectedDevice", "capability.switch", title: "Select Device"
    input "debugLogging", "bool", title: "Enable Debug Logging"
    input "operationMode", "enum", options: ["auto", "manual"]
}

def installed() {
    // Clear, unambiguous access
    selectedDevice.on()
    
    // Explicit settings access when in doubt
    if (settings.debugLogging) {
        log.debug "Debug enabled"
    }
}

This is a classic trade-off in DSL design - convenience and clean syntax vs. potential namespace pollution. It's probably why experienced Hubitat developers are careful with input naming conventions to avoid conflicts with common method names like initialize, updated, installed, etc.

Have you run into specific naming conflicts in practice, or discovered the actual resolution order through testing?

the conflict that was run into was that at some point the app had an input that was later removed, but it persisted in the app state and later collided with another object.

Ah, that's a really insidious issue! This highlights a significant problem with Hubitat's dynamic property injection system.

The Persistence Problem

When you remove an input from the preferences section, Hubitat doesn't automatically clean up the corresponding entry in the settings map. So you end up with:

groovy

// Original preferences
preferences {
    input "device", "capability.switch", title: "Select Device"
    input "mode", "enum", options: ["auto", "manual"], title: "Mode"
}

// Later, you remove "mode" and add a local variable or method
preferences {
    input "device", "capability.switch", title: "Select Device"
    // "mode" input removed
}

def someMethod() {
    def mode = "newValue"  // Local variable
    
    // But settings.mode still exists from the old input!
    // Property resolution might still find the old settings value
    log.debug "Mode is: ${mode}"  // Which "mode" gets resolved?
}

The Collision

The old settings.mode value persists even though the input definition is gone, creating a zombie property that can:

  • Shadow new local variables with the same name
  • Conflict with new methods or properties
  • Cause unexpected behavior since the property still exists in the resolution chain

Solutions

1. Explicit Settings Cleanup:

groovy

def installed() {
    // Manually clean up removed settings
    settings.remove("mode")
    // or
    app.removeSetting("mode")
}

2. Settings Reset:

groovy

def updated() {
    // Nuclear option - clear all settings and let preferences rebuild
    settings.clear()
}

3. Defensive Naming:

groovy

// Use prefixed naming to avoid conflicts
preferences {
    input "cfg_deviceSelection", "capability.switch"
    input "cfg_operationMode", "enum", options: ["auto", "manual"]
}

This is a great example of why some developers prefer always using explicit settings.propertyName access - it makes the namespace clear and avoids these subtle conflicts. Have you found a reliable way to clean up these orphaned settings?

you state an opinion that this is a "significant problem" with Hubitat's system. On what basis did you make this assessment? State the pros and cons.

You're right to challenge my characterization. Let me provide a more balanced analysis:

Pros of Hubitat's Direct Property Access

Developer Experience:

  • Clean, intuitive syntax (mySwitch.on() vs settings.mySwitch.on())
  • Reduces boilerplate code
  • Makes preferences feel like natural class properties
  • Easier for newcomers to understand and use

Code Readability:

  • Less visual noise in method implementations
  • More declarative style
  • Aligns with Groovy's dynamic property philosophy

Framework Integration:

  • Seamless UI generation from preferences
  • Automatic type handling and validation
  • Consistent with SmartThings heritage (familiar to migrating developers)

Cons of Hubitat's Direct Property Access

Namespace Issues:

  • Potential conflicts with method names, local variables, class properties
  • Orphaned settings persist after input removal
  • Ambiguous property resolution order

Debugging Complexity:

  • Harder to distinguish between different property sources
  • Runtime resolution makes static analysis difficult
  • Unexpected behavior when names collide

Maintenance Challenges:

  • No automatic cleanup of removed inputs
  • Refactoring can introduce subtle bugs
  • IDE support may be limited for dynamic properties

Assessment

Rather than a "significant problem," it's more accurate to say this is a design trade-off. Hubitat prioritized developer convenience and code readability over namespace safety.

The persistence issue you encountered is a legitimate concern, but it's manageable with proper naming conventions and awareness. Many successful Hubitat apps use this system effectively.

The "significance" really depends on your development context - hobby automation vs. commercial products, team size, maintenance requirements, etc.

Would you say the benefits outweigh the drawbacks in your experience?

5 Likes

Feels like dealing with some tech support for telco's....

Because I looked at the app details.

The code below is a fully functioning app. It uses the input named "runTime" to set a schedule.

To demonstrate the bug:

  • Paste the code into the "Apps code", save it, and then add the app.
  • Select at least one device that is not disabled, set a report type and schedule, and click "Done".
  • Check the app log, should be no errors.
  • Check the app details, should have a settings entry for "runTime".
  • Now MANUALLY change "runTime" (but not "runTimeLabel") in lines 62, 87, 150, 151, and 152 to "schedule" (as if you were a newby and hadn't read this thread).
  • Run the app and re-set the schedule, then click "Done". Line 152 will generate an error.
  • Check the app details, should have a settings entry for "runTime" and "schedule" -- even though "runTime" does not exist in the app code anymore.
  • Now MANUALLY change "schedule" in lines 62, 87, 150, 151, and 152 back to "runTime" making sure that in line 152, you don't change the method call -- the post-edit line should read "schedule(runTime, handlerX)".
  • Run the app and re-set the schedule if need be, then click "Done". Line 152 will generate an error.
  • Check the app details, should have a settings entry for "runTime" and "schedule" -- even though "schedule" does not exist in the app code anymore.
  • And then NOTHING you do to the app code (as in, hours of attempts) will make the error go away. The only solution at this point is to copy the code into a new app code instance and add that new instance as an app.
  • I think that's a bug.

And kudos to @sburke781 for finding the ultimate source of the problem.

definition(
    name: "Hubitat Bug Tester",
    namespace: "hubitat",
    author: "based on 'Battery Notifier' by Bruce Ravenel, modified by John Land via ChatGPT",
    description: "Battery Device Status with battery %, event description sorting, offline reporting, and all-tables option",
    installOnOpen: true,
    category: "Convenience",
    iconUrl: "",
    iconX2Url: ""
)

/*
PURPOSE: Check the state of battery based devices
FEATURES:
    * (2025-08-15) Shows only devices that report a "battery" capability.
    * (2025-08-15) Selectively shows devices based on "last battery event" or "last event of any kind".
    * (2025-08-15) Configurable reporting interval and reporting time.
    * (2025-08-15) Output is in a sortable table for ease of reading.
    * (2025-08-15) The parenthetical details in the section headings can be suppressed.
    * (2025-08-18) Multiple notification devices supported.
    * (2025-08-18) Plain-text table for notification devices (e.g., Pushover, Hubitat)
    * (2025-08-18) "[Never]" is red only in "Check Now" view, plain in notifications.
    * (2025-08-19) Some more cosmetic changes to the plain text "table" for notification devices (e.g., Pushover, Hubitat)
    * (2025-08-19) More cosmetic changes to output tables
    * (2025-08-21) Added ability to report only OFFLINE devices
    * (2025-08-22) Added "All Tables" option (sequentially outputs Any, Offline, Battery reports)
    * (2025-08-22) Independent sorting per section when "All Tables" is selected
*/

preferences {
    page(name: "mainPage")
}

def mainPage() {
    dynamicPage(name: "mainPage", title: "Battery Device Status", uninstall: true, install: true) {
        // Device selection
        def devCount = devs?.findAll { !it.isDisabled() }?.size() ?: 0
        def devTitle = "Battery Device Selection"
        if (showSectionDetails) devTitle += " (${devCount} selected)"
        section(hideable:true, hidden:true, title: devTitle) {
            input "devs", "capability.battery",
                title: "Select devices (disabled devices will be omitted even if selected)",
                submitOnChange:true, multiple:true, required:false
        }

        // Notification devices
        def noticeNames = []
        if (notice) {
            noticeNames = (notice instanceof Collection) ? notice*.displayName : [notice.displayName]
        }
        def noticeLabel = noticeNames ? noticeNames.join(", ") : "Select notification device(s)"
        def noticeTitle = "Notification Settings"
        if (showSectionDetails) noticeTitle += " (${noticeLabel})"
        section(hideable:true, hidden:true, title: noticeTitle) {
            input "notice", "capability.notification",
                title:"Select notification device(s)",
                submitOnChange:true, multiple:true, required:true
        }

        // Report Type, Scheduling & Logging
        def reportIntervalLabel = reportIntervalHours ?: 24
        def runTimeLabel = runTime ? timeToday(runTime, location.timeZone).format("hh:mm a") : ""
        def loggingLabel = enableLogging ? "Enabled" : "Disabled"
        def reportTypeLabel = [
            "any":"Last Event (any type)",
            "offline":"Offline Devices Only",
            "battery":"Last Battery Event Only",
            "all":"All Tables"
        ][reportType] ?: "Last Battery Event Only"

        def optionsTitle = "Report Type, Schedule & Logging"
        if (showSectionDetails) {
            optionsTitle += " (Report: ${reportTypeLabel}, Interval: ${reportIntervalLabel}h, Daily Check: ${runTimeLabel}, Logging: ${loggingLabel})"
        }
        section(hideable:true, hidden:true, title: optionsTitle) {
            input "reportType", "enum",
                title:"Select which event type to check for reporting",
                options:[
                    "battery":"Last Battery Event Only",
                    "any":"Last Event (any type)",
                    "offline":"Offline Devices Only",
                    "all":"All Tables"
                ],
                defaultValue:"battery", required:true

            input "reportIntervalHours", "number", title:"Reporting interval in hours (default = 24)", defaultValue:24, required:true
            input name:"runTime", type:"time", title:"Daily check time", required:true
            input "showSectionDetails", "bool", title:"Show extra details in section headers?", defaultValue:true
            input "enableLogging", "bool", title:"Enable debug logging?", defaultValue:false
        }

		// Sort Options (always visible)
		def sortTitle = "Sort Options"
		def sortByLabel = "Last Event Time"
		def sortOrderLabel = sortOrder == "asc" ? "Ascending" : "Descending"

		switch(sortBy) {
			case "displayName": sortByLabel = "Device Name"; break
			case "lastStr": sortByLabel = "Last Event Time"; break
			case "level": sortByLabel = "Battery %"; break
			case "lastEventStr": sortByLabel = "Event Description"; break
		}
		if (reportType == "offline") sortByLabel = "Device Name"

		if (showSectionDetails) sortTitle += " (Sort by: ${sortByLabel}, Order: ${sortOrderLabel})"

		section(hideable:true, hidden:true, title: sortTitle) {
			if (reportType == "offline") {
				// Only sortOrder applies
				input "sortOrder","enum",
					title:"Order",
					options:["asc":"Ascending","desc":"Descending"],
					defaultValue:sortOrder ?: "asc",
					submitOnChange:true
			} else {
				// SortBy + SortOrder
				def sortOptions = ["displayName":"Device Name","lastStr":"Last Event Time","level":"Battery %"]
				if (reportType == "any" || reportType == "all") {
					sortOptions["lastEventStr"] = "Event Description"
				}
				input "sortBy","enum",
					title:"Sort by",
					options:sortOptions,
					defaultValue:sortBy ?: "lastStr",
					submitOnChange:true
				input "sortOrder","enum",
					title:"Order",
					options:["asc":"Ascending","desc":"Descending"],
					defaultValue:sortOrder ?: "asc",
					submitOnChange:true
			}
		}

        // Check Now
        section(title:"") {
            input "check","button", title:"Check Now", state:"check"
            if (state.check) {
                paragraph handler()
                state.check = false
            }
        }
    }
}

def installed() { initialize() }

void initialize() {
    unschedule()
    if (enableLogging) log.debug "Battery Device Status initializing ..."
    if (runTime) {
        if (enableLogging) log.debug "Battery Device Status scheduled a daily check at ${runTime}"
        schedule(runTime, handlerX)
    }
    log.info "Battery Device Status initialized with ${devs?.size() ?: 0} devices"
}

def updated() {
    unschedule()
    unsubscribe()
    initialize()
    if (enableLogging) log.debug "Battery Device Status updated with ${devs?.size() ?: 0} devices"
}

void handlerX() {
    if (enableLogging) log.debug "Battery Device Status scheduled check is running..."
    handler(true)
}

// ----------------------
// Main Handler
// ----------------------
String handler(note=false) {
    if (reportType == "all") {
        def sections = ["any":"Last Event (any type)", "offline":"Offline Devices Only", "battery":"Last Battery Event Only"]
        def htmlOut = ""
        def plainOut = ""
        sections.eachWithIndex { type, label, idx ->
            def result = generateReport(type, note=false)
            htmlOut += "<h3>${label}</h3>" + result.html
            if (idx < sections.size()-1) htmlOut += "<hr><br>"
            plainOut += "=== ${label} ===\n" + result.plain + "\n\n"
        }
        if (note) {
            (notice ?: []).each { n -> n.deviceNotification(plainOut.trim()) }
        } else {
            return htmlOut
        }
    } else {
        def result = generateReport(reportType, note)
        return result.html
    }
}

// ----------------------
// Report Generator
// ----------------------
private Map generateReport(String type, boolean noteMode) {
    def sortByVal = (type=="offline") ? "displayName" : (settings.sortBy ?: "lastStr")
    def sortOrderVal = (type=="offline") ? "asc" : (settings.sortOrder ?: "asc")
    def selectedEnabledDevices = (devs ?: []).findAll{!it.isDisabled()}
    if (!selectedEnabledDevices) return [html:"No battery devices found.", plain:"No battery devices found."]

    def rightNow = new Date()

    def reportList = selectedEnabledDevices.collect { dev ->
        def lastEvent = (type=="any") ? dev.events(max:1)?.find{true} : dev.events(max:50)?.find{ it.name=="battery" }
        def lastEventDate = lastEvent?.date
        def eventDesc = (type=="any" && lastEvent) ? "(Event: ${lastEvent.name} ${lastEvent.value})" : ""
        def fs = '\u2007'
        def batteryLevel = dev.currentBattery != null ? Math.round(dev.currentBattery).toString().padLeft(3, fs) : "N/A".padLeft(3, fs)
        def devStatus = dev.getStatus()?.toUpperCase()
        def isOffline = devStatus in ["OFFLINE", "INACTIVE", "NOT PRESENT"] || (dev.currentHealthStatus?.toLowerCase() == "offline")
        def needsNotice = (type=="offline" ? isOffline : !lastEventDate || ((rightNow.time - lastEventDate.time)/60000) > ((reportIntervalHours ?: 24) * 60))
        String lastStrUI   = lastEventDate ? lastEventDate.format("yyyy-MM-dd hh:mm a", location.timeZone) : "<span style='color:red;'>[Never]</span>"
        String lastStrNote = lastEventDate ? lastEventDate.format("yyyy-MM-dd hh:mm a", location.timeZone) : "0000-00-00 00:00 xx"
        [device:dev, lastDate:lastEventDate, lastStrUI:lastStrUI, lastStrNote:lastStrNote,
         lastEventStr:eventDesc, level:batteryLevel, offline:isOffline, needs:needsNotice]
    }.findAll{ it.needs }

    if (type=="offline" && reportList.size()==0) {
        def msg = "No offline devices found."
        return [html:msg, plain:msg]
    }

    // Sorting
    reportList = reportList.sort { entry ->
        switch(sortByVal) {
            case "displayName": return entry.device.displayName.toLowerCase()
            case "lastStr": return entry.lastDate ?: new Date(0)
            case "level": return (entry.level.toString().isNumber() ? entry.level.toInteger() : -1)
            case "lastEventStr": return entry.lastEventStr?.toLowerCase() ?: ""
            default: return entry.lastDate ?: new Date(0)
        }
    }
    if (sortOrderVal=="desc") reportList = reportList.reverse()

    def totalChecked = selectedEnabledDevices.size()
    def notReportedCount = reportList.size()

    def headerText = type=="offline" ? "${notReportedCount} of ${totalChecked} selected device(s) are OFFLINE:\n\n" :
        (notReportedCount>0 ? "${notReportedCount} of ${totalChecked} selected device(s) did not report:\n\n" : "All devices reported (${totalChecked} device${totalChecked!=1?'s':''})")

    // HTML
    def headerHtml = headerText.replace("\n\n","<br><br>")
    def tableHtml = ""
    if (notReportedCount>0) {
        tableHtml = "<table border='1' cellpadding='4' cellspacing='0' style='border-collapse:collapse;width:100%'><thead><tr>"
        tableHtml += "<th>Last Event Time</th><th>Battery %</th><th>Device Name</th>"
        if (type=="any") tableHtml += "<th>Event Description</th>"
        tableHtml += "</tr></thead><tbody>"
        reportList.each { entry ->
            def dev = entry.device
            tableHtml += "<tr>"
            tableHtml += "<td>${entry.lastStrUI}</td><td>${entry.level}%</td><td><a href='/device/edit/${dev.id}' target='_blank'>${dev.displayName}</a></td>"
            if (type=="any") tableHtml += "<td>${entry.lastEventStr}</td>"
            tableHtml += "</tr>"
        }
        tableHtml += "</tbody></table>"
    }

    // Plain text
    def rows = []
    if (notReportedCount>0) {
        def header = "Last Event Time            Battery %   Device Name"
        if (type=="any") header += "   Event Description"
        rows << header
        rows << "-" * header.size()
        reportList.each { entry ->
            def row = "${entry.lastStrNote}   ${entry.level.toString()}%        ${entry.device}"
            if (type=="any") row += "   ${entry.lastEventStr}"
            rows << row
        }
    }
    def plainMsg = headerText + (rows ? "\n" + rows.join("\n") : "")

    if (noteMode) {
        (notice ?: []).each { n -> n.deviceNotification(plainMsg) }
    }

    return [html: headerHtml + tableHtml, plain: plainMsg]
}

1 Like

Are you sure that app.removeSetting("schedule") won't make the error go away?

You'd have to know the origin of the problem for that to come to mind and have to know that such a command existed and could be used to fix a problem that is not apparent from the app code itself.

A newbie like me needed help from @sburke781 to even figure out why line 152 threw an error, particularly since "schedule" is not a reserved word in Groovy, so the Groovy documentation wasn't helping me.

So NOW I know that there is retained information within Hubitat that secretly affects the running of plain text app code that no longer contains that information. Lesson learned.

Well, yes, this is how all development environments work. :slight_smile: The developer documentation I linked to in my post has this information. It sounds like Chat GPT does not.

You are correct that errors in dynamic languages such as Groovy can be difficult to decode. But ultimately, the error makes sense in the end that a string (the value of what essentially becomes a field variable named schedule) doesn't have any methods you can call on it that take two string parameters, which is ultimately what this code run in this context ended up doing. Of course, the fact that it took several people this long to get you there should make it apparent that (inspecting App State was the key in this case and) these things can indeed be tricky.

Or: welcome to programming. :smiley:

The fact that you removed and re-added the app code is also somewhat of a red herring, BTW. You would just have needed to install a new instance of the app itself (removing the app code just forces you to remove the app first, so this is the only possible outcome from that path). Even that, which as I hinted above is not the only way to get here, is only because of app data previous executions of your app created and stored -- which is normally very much a feature.

4 Likes