Is there a way to automate adding/removing device tags?

I think i’ve done my due diligence, I’ve checked the forums and RM and i dont see a way to automate adding / removing device tags. Am i missing something or would this be more of a feature request?

My use case is i’d like to automate battery replacement tracking. In my head… when the battery % jumps by 50% or more add a tag that the battery was replaced on that date. That way my environment can auto document itself.

2 Likes

I believe the answer is a hard "No." For now, it seems both Tags and Notes are manual entries that cannot be automated, nor are they exposed/set by MakerAPI.

But I've also never tried setting either parameter in Groovy code. Can that be done??

You may have to ask @gopher.ny as this is a relatively new feature.

I would expect you would require something to detect the change in the battery percentage, which I would not expect you will see built in. Building a custom App is the more likely solution. I could think of a few likely candidates for writing one... :wink:

Not aware of an API either, so not using them. The only endpoint I found is http://your.hub.ip/device/availableTags which returns a comma-separated (apparently global) list of available device tags, but won't let you update a particular device's tags.

I think there's an app by @thebearmay that would let you enter that info as "device data"

Had a quick look and it is possible to hack something together via http calls to get and update the device, but would be much better if appropriate APIs or endpoints were available.

Could an app not detect an increase in the battery percentage? Or even a driver... I know I have done it in a driver... So likely an App would not be required.... For:

I could be wrong but I think the OP's request is not about detecting that change but about a way to store the replacement date information with the device.

I thought this could be done through an app/driver rather than requiring manual intervention....

Yes, Detecting the change in battery percent is easy, i can do that in RM. Using that change to automate the update of a device tag would be the hard part.
I’ve done enough testing in curl to know that i can use http://hubitat.local:8080/device/fullJson/deviceid to pull the data needed to update the device tags with
http://hubitat.local:8080/device/update
but i know updating the device improperly will destroy the device, so there be dragons here and these dragons i’m not smart enough to fight yet.

1 Like

It feels like @thebearmay could play the part of St George in this pantomime....

1 Like

I dropped some tokens on Claude

Use at own risk (hopefully contributes to motivate @gopher.ny to expose proper endpoints/APIs for this :wink: )

EDIT: note that it is the device note that gets updated, not the tags. The same approach applies to tags, I suppose.

import groovy.transform.Field

// Maximum replacement entries retained per device in state.replacementHistory
@Field static final int HISTORY_LIMIT = 20

// Fields that must be present in the /device/fullJson response before we attempt
// the /device/update POST. If any are missing it likely means a firmware change
// broke the field mapping; we skip the notes update and warn rather than send
// a malformed request.
@Field static final List<String> REQUIRED_DEVICE_FIELDS = [
    "id", "version", "name", "label", "deviceNetworkId", "deviceTypeId", "locationId", "hubId"
]

definition(
    name: "Battery Change Logger",
    namespace: "dragons",
    author: "slayer",
    description: "Monitors battery levels and appends a timestamped note to device notes when a replacement is detected",
    category: "Utility",
    iconUrl: "",
    iconX2Url: ""
)

preferences {
    page(name: "mainPage")
}

Map mainPage() {
    dynamicPage(name: "mainPage", title: "Battery Change Logger", install: true, uninstall: true) {
        section("Devices") {
            input "batteryDevices", "capability.battery",
                title: "Devices to monitor",
                required: true, multiple: true
        }
        section("Settings") {
            input "threshold", "number",
                title: "Battery increase threshold (%)",
                description: "Treat a battery level increase of at least this amount as a replacement",
                required: true, defaultValue: 20, range: "1..99"
            paragraph "<span style='color:red'>EXPERIMENTAL</span>"
            input "updateDeviceNotes", "bool",
                title: "Update device notes on replacement",
                description: "Append a timestamped entry to the device's Notes field when a replacement is detected",
                defaultValue: true, required: false
        }
        section("Notifications") {
            input "notifyDevice", "capability.notification",
                title: "Notification device (optional)",
                required: false, multiple: false
            if (notifyDevice) {
                input "notifyIntervalDays", "number",
                    title: "Notify if battery lasted fewer than (days) - 0 to always notify",
                    description: "Send a notification when the interval since the previous replacement is shorter than this many days. Use 0 to always notify (useful for testing).",
                    required: false, range: "0..3650"
            }
        }

        Map history = state.replacementHistory
        if (history) {
            // Sort device groups by most recent entry date, newest first
            List<String> deviceIds = history.keySet().toList()
            deviceIds.sort { String a, String b ->
                String dateA = ((history[a] as List).max { it.date })?.date ?: ""
                String dateB = ((history[b] as List).max { it.date })?.date ?: ""
                dateB <=> dateA
            }
            section("Replacement History") {
                String td = "style='border:1px solid #999;padding:4px 8px'"
                String tdC = "style='border:1px solid #999;padding:4px 8px;text-align:center'"
                String table = "<table style='border-collapse:collapse;width:100%'>" +
                    "<thead><tr style='background:#ddd'>" +
                    "<th ${td}>Date</th><th ${td}>Device</th>" +
                    "<th ${tdC}>Prev</th><th ${tdC}>New</th><th ${tdC}>Notes</th>" +
                    "</tr></thead><tbody>"
                deviceIds.each { String deviceId ->
                    List entries = ((history[deviceId] ?: []) as List).sort { a, b -> b.date <=> a.date }
                    if (!entries) return
                    String deviceLabel = (entries[0].label ?: deviceId) as String
                    entries.each { entry ->
                        String notesCell = ""
                        if ((entry as Map).containsKey("notesUpdated")) {
                            notesCell = entry.notesUpdated ? "&#10003;" : "&#10007;"
                        }
                        table += "<tr>" +
                            "<td ${td}>${entry.date}</td>" +
                            "<td ${td}>${deviceLabel}</td>" +
                            "<td ${tdC}>${entry.oldLevel}%</td>" +
                            "<td ${tdC}>${entry.newLevel}%</td>" +
                            "<td ${tdC}>${notesCell}</td>" +
                            "</tr>"
                    }
                }
                table += "</tbody></table>"
                paragraph table
            }
        }

        section("Logging") {
            input "logLevel", "enum",
                title: "Log level",
                options: ["warn", "info", "debug"],
                defaultValue: "info", required: true
        }
    }
}

void installed() {
    logDebug "installed()"
    initialize()
}

void updated() {
    logDebug "updated()"
    unsubscribe()
    initialize()
}

void uninstalled() {
    logDebug "uninstalled()"
}

void initialize() {
    logDebug "initialize()"
    if (state.batteryLevels == null) {
        state.batteryLevels = [:]
    }
    if (state.replacementHistory == null) {
        state.replacementHistory = [:]
    }
    subscribe(batteryDevices, "battery", "batteryHandler")
    seedInitialLevels()
}

// Seed current battery level for any device not yet tracked.
// Called on every initialize() so newly-added devices are picked up
// without resetting levels for devices already being tracked.
void seedInitialLevels() {
    Map levels = state.batteryLevels ?: [:]
    boolean changed = false
    batteryDevices.each { device ->
        String deviceId = device.id.toString()
        if (!levels.containsKey(deviceId)) {
            String currentVal = device.currentValue("battery")?.toString()
            if (currentVal != null) {
                levels[deviceId] = currentVal.toInteger()
                changed = true
                logDebug "Seeded ${device.displayName} at ${currentVal}%"
            }
        }
    }
    if (changed) {
        state.batteryLevels = levels
    }
}

void batteryHandler(evt) {
    String deviceId = evt.deviceId.toString()
    String deviceLabel = evt.displayName
    int newLevel = evt.value.toInteger()

    Map levels = state.batteryLevels ?: [:]
    Integer lastLevel = levels[deviceId] != null ? (levels[deviceId] as Integer) : null

    if (lastLevel != null) {
        int delta = newLevel - lastLevel
        logDebug "${deviceLabel}: ${lastLevel}% -> ${newLevel}% (delta: ${delta}%)"
        if (delta >= (settings.threshold as Integer)) {
            logInfo "Battery replacement detected on ${deviceLabel}: ${lastLevel}% -> ${newLevel}%"
            // Use now() as a unique entry ID so the async callback can find and update
            // this exact entry when the notes POST result comes back.
            long entryId = now()
            String timestamp = new Date().format("yyyy-MM-dd HH:mm", location.timeZone)
            // Persist to history immediately — authoritative record, no API dependency.
            // Check interval against the previous entry before the new one is appended
            if (notifyDevice && settings.notifyIntervalDays != null) {
                checkIntervalAndNotify(deviceId, deviceLabel, lastLevel, newLevel, settings.notifyIntervalDays as int)
            }

            addToHistory(deviceId, deviceLabel, lastLevel, newLevel, entryId, timestamp)

            // Best-effort: also append the entry to device notes via the internal API.
            if (updateDeviceNotes != false) {
                fetchDeviceAndLog(deviceId, deviceLabel, lastLevel, newLevel, entryId, timestamp)
            }
        }
    } else {
        logDebug "${deviceLabel}: first reading, seeding at ${newLevel}%"
    }

    // Always update stored level so the next event has an accurate baseline
    levels[deviceId] = newLevel
    state.batteryLevels = levels
}

// Compare the current replacement against the previous one for this device and notify if
// the interval is shorter than thresholdDays. Called before addToHistory so the last
// entry in the list is still the previous replacement, not the current one.
// Uses entry.id (epoch ms from now()) for the interval — no timezone-sensitive parsing needed.
private void checkIntervalAndNotify(String deviceId, String deviceLabel, int oldLevel, int newLevel, int thresholdDays) {
    List entries = (state.replacementHistory?.get(deviceId) ?: []) as List
    boolean hasHistory = !entries.isEmpty()
    if (!hasHistory && thresholdDays != 0) {
        logDebug "${deviceLabel}: no previous replacement on record, skipping interval check"
        return
    }
    int elapsedDays = 0
    if (hasHistory) {
        Map lastEntry = entries.last() as Map
        long elapsedMs = now() - (lastEntry.id as long)
        elapsedDays = (elapsedMs / (1000L * 60 * 60 * 24)) as int
    }
    logDebug "${deviceLabel}: ${hasHistory ? "${elapsedDays}d since last replacement" : "no prior history"} (notify threshold: ${thresholdDays}d)"
    if (thresholdDays == 0 || elapsedDays < thresholdDays) {
        String msg = thresholdDays == 0
            ? "Battery changed: ${deviceLabel} ${oldLevel}% -> ${newLevel}%"
            : "Short battery life: ${deviceLabel} replaced after only ${elapsedDays} day${elapsedDays == 1 ? '' : 's'} " +
              "(threshold: ${thresholdDays}d). ${oldLevel}% -> ${newLevel}%"
        notifyDevice.deviceNotification(msg)
        logInfo "Interval notification sent for ${deviceLabel}: ${elapsedDays}d since last replacement"
    }
}

// Write a new entry to state.replacementHistory with no notesUpdated key (outcome pending).
// notesUpdated is set to true/false by updateNotesStatus() once the async POST completes.
// When updateDeviceNotes is false the key is never added, so the history display omits
// the notes status for that entry.
private void addToHistory(String deviceId, String deviceLabel, int oldLevel, int newLevel, long entryId, String timestamp) {
    Map history = state.replacementHistory ?: [:]
    List entries = (history[deviceId] ?: []) as List
    entries << [id: entryId, date: timestamp, label: deviceLabel, oldLevel: oldLevel, newLevel: newLevel]
    if (entries.size() > HISTORY_LIMIT) {
        entries = entries.drop(entries.size() - HISTORY_LIMIT)
    }
    history[deviceId] = entries
    state.replacementHistory = history
}

// Find the history entry by ID and stamp the notes outcome.
// Called from every success and failure path in the async chain.
private void updateNotesStatus(String deviceId, long entryId, boolean success) {
    Map history = state.replacementHistory ?: [:]
    List entries = (history[deviceId] ?: []) as List
    int idx = entries.findIndexOf { (it.id as long) == entryId }
    if (idx >= 0) {
        // Map + Map merges entries, right side wins on duplicate keys
        entries[idx] = entries[idx] + [notesUpdated: success]
        history[deviceId] = entries
        state.replacementHistory = history
    }
}

// Step 1: fetch the current device JSON so we can echo all fields back in the POST
void fetchDeviceAndLog(String deviceId, String deviceLabel, int oldLevel, int newLevel, long entryId, String timestamp) {
    Map params = [
        uri        : "http://127.0.0.1:8080",
        path       : "/device/fullJson/${deviceId}",
        contentType: "application/json"
    ]
    Map callbackData = [
        deviceId   : deviceId,
        deviceLabel: deviceLabel,
        oldLevel   : oldLevel,
        newLevel   : newLevel,
        entryId    : entryId,
        timestamp  : timestamp
    ]
    try {
        asynchttpGet("handleFullJsonResponse", params, callbackData)
    } catch (Exception e) {
        logError "(notes) Error fetching device data for ${deviceLabel}: ${e.message}"
        updateNotesStatus(deviceId, entryId, false)
    }
}

// Step 2: validate the response, append the note entry, and POST all device fields back
void handleFullJsonResponse(resp, data) {
    String deviceId = data.deviceId
    long entryId = data.entryId as long

    if (resp.hasError()) {
        logWarn "(notes) Error fetching device data for ${data.deviceLabel}: ${resp.getErrorMessage()} — replacement recorded in app history"
        updateNotesStatus(deviceId, entryId, false)
        return
    }
    if (resp.status != 200) {
        logWarn "(notes) HTTP ${resp.status} fetching device data for ${data.deviceLabel} — replacement recorded in app history"
        updateNotesStatus(deviceId, entryId, false)
        return
    }

    try {
        Map json = resp.json
        Map device = json.device

        // Guard: if required fields are missing the /device/fullJson contract has changed.
        // Skip the POST rather than send a malformed request.
        List<String> missing = REQUIRED_DEVICE_FIELDS.findAll { device[it] == null }
        if (!missing.isEmpty()) {
            logWarn "(notes) /device/fullJson missing expected fields: ${missing.join(', ')} — Hubitat firmware may have changed the API. Notes update skipped; replacement is recorded in app history."
            updateNotesStatus(deviceId, entryId, false)
            return
        }

        // Use the timestamp generated in batteryHandler so the note entry matches what's
        // stored in state.replacementHistory exactly.
        String newEntry = "[${data.timestamp}] Battery replaced: ${data.oldLevel}% -> ${data.newLevel}%"
        String existingNotes = ((device.notes ?: "") as String).trim()
        String updatedNotes = existingNotes ? "${newEntry}\n${existingNotes}" : newEntry

        // /device/update expects selected dashboard IDs as a comma-separated string
        List dashboards = (json.dashboards ?: []) as List
        String dashboardIds = dashboards.findAll { it.selected }.collect { it.id.toString() }.join(",")

        // Echo all device fields back; only notes changes.
        // Booleans must be "on" (true) or "false" (false) — standard HTML checkbox encoding.
        Map postFields = [
            id                    : device.id.toString(),
            version               : device.version.toString(),
            name                  : (device.name ?: "") as String,
            label                 : (device.label ?: "") as String,
            zigbeeId              : (device.zigbeeId ?: "") as String,
            maxEvents             : device.maxEvents.toString(),
            maxStates             : device.maxStates.toString(),
            spammyThreshold       : device.spammyThreshold.toString(),
            deviceNetworkId       : (device.deviceNetworkId ?: "") as String,
            deviceTypeId          : device.deviceTypeId.toString(),
            deviceTypeReadableType: (device.deviceTypeReadableType ?: "") as String,
            roomId                : (device.roomId ?: 0).toString(),
            meshEnabled           : device.meshEnabled ? "on" : "false",
            retryEnabled          : device.retryEnabled ? "on" : "false",
            meshFullSync          : device.meshFullSync ? "on" : "false",
            homeKitEnabled        : (json.homeKitEnabled ?: false) ? "on" : "false",
            locationId            : device.locationId.toString(),
            hubId                 : device.hubId.toString(),
            groupId               : (device.groupId ?: 0).toString(),
            dashboardIds          : dashboardIds,
            defaultIcon           : (device.defaultIcon ?: "") as String,
            tags                  : (device.tags ?: "") as String,
            notes                 : updatedNotes,
            controllerType        : (device.controllerType ?: "") as String
        ]

        // Manually URL-encode the body so special characters (%, newlines, brackets, etc.)
        // in field values (especially notes) are safely transmitted.
        // requestContentType registers the correct encoder so the string is passed through as-is.
        String body = postFields.collect { k, v ->
            URLEncoder.encode(k as String, "UTF-8") + "=" + URLEncoder.encode(v as String, "UTF-8")
        }.join("&")

        Map postParams = [
            uri               : "http://127.0.0.1:8080",
            path              : "/device/update",
            requestContentType: "application/x-www-form-urlencoded",
            body              : body,
            textParser        : true   // don't try to JSON-parse the redirect response
        ]
        asynchttpPost("handleUpdateResponse", postParams, [
            deviceId   : deviceId,
            deviceLabel: data.deviceLabel,
            entryId    : entryId,
            notes      : updatedNotes
        ])
    } catch (Exception e) {
        logError "(notes) Error processing device data for ${data.deviceLabel}: ${e.message}"
        updateNotesStatus(deviceId, entryId, false)
    }
}

// Step 3: confirm the update succeeded and stamp the history entry.
// POST /device/update returns 302 (redirect to device edit page) on success;
// the async HTTP client may follow it and deliver 200, so accept both.
void handleUpdateResponse(resp, data) {
    int status = resp.status
    boolean success = (status == 200 || status == 302)
    updateNotesStatus(data.deviceId, data.entryId as long, success)
    if (success) {
        logInfo "Battery replacement logged to device notes for ${data.deviceLabel}"
        logDebug "Notes: ${data.notes}"
    } else {
        logWarn "(notes) Failed to update device notes for ${data.deviceLabel}: HTTP ${status} — replacement is recorded in app history"
        if (resp.hasError()) logWarn "(notes) ${resp.getErrorMessage()}"
        // Log response body at debug level to aid diagnosis of future API changes
        String responseBody = resp.data?.toString()
        if (responseBody) logDebug "(notes) Response body: ${responseBody}"
    }
}

// ---- Logging helpers ----

private void logDebug(String msg) {
    if (logLevel == "debug") log.debug "${app.getLabel()}: ${msg}"
}

private void logInfo(String msg) {
    if (logLevel in ["info", "debug"]) log.info "${app.getLabel()}: ${msg}"
}

private void logWarn(String msg) {
    log.warn "${app.getLabel()}: ${msg}"
}

private void logError(String msg) {
    log.error "${app.getLabel()}: ${msg}"
}

3 Likes

Mood Log Off GIF

1 Like

Didn't see anyone post the link to this yet, so...

2 Likes

That will work.

The nice thing about tags and notes is that unlike device data, the admin UI surfaces them in device lists, home page, etc.

Yeah i looked into that app too, but i didn’t see a way to automate an update to the fields. unless i missed something, functionally It seems to serve a similar purpose as tags. @hubitrep mentioned with tags or device note I can search for “battery replaced” on the device list and see all the devices with tags that contain “battery replaced” and their dates.

My goal is something like bathroom shower, but the date updates itself when there are large increases in battery percent. ie 30% -> 100%

1 Like

That was my thought from the get go.

Is there a reason to use the tags or notes instead of just a dedicated app for battery tracking? An app could be made to track batteries. You could select the devices to track. You could manually input the battery types for each one. It could track the percentages in the app and under defined circumstances it would reset the last replaced date.

You could have buttons to manually reset or set the date as well.

Seems like it would much easier to make an app that does that vs trying to do hacks around the tags and notes.

Take the layout of something like my SS Manager app, and instead of reading the SS list rom the hub, read from a user selected list of devices. Change the columns and there you go.

1 Like

See @sidjohn1 's screenshot just above your post.

I think the takeaway here is that tags and notes could be made more useful if they could be set programmatically rather than only manually.

3 Likes

Yeah maybe, I don't think that's really the intention of the notes and tags. At the same time there are better ways to accomplish specifically what OP is after.

I think the main point of using RM and notes/tags is for someone who wants to do this on their own without having to make a new app for it.

I don't think so


This is another great idea, and there are several battery tracking apps that are 80% of the way there. I felt the data would be easier to consume for more users if tags or device notes were used

I don't think it was requested that it be visible on a dashboard?
Not a bad idea, but I did not see that it was requested.

I think that just really depends on how the app is going to present the data. A report page could be made that shows all the devices selected and relevant data on a single page. No remembering what special keywords you have to search the device list for.

Now if you did want that same data to be replicated into notes or tags, that would be where having and endpoint the app could use would come in handy.