[RELEASE] Plugwise Adam Driver

Hey guys, Ive just got a plugwise Adam HA and an Emma pro. I noticed there is no geofencing or presence detection, so i wanted to get that. This means i had a need for pausing of the weekly schedule. Could not find a driver so I was fiddling with Comet and ChatGPT. (since i am not a programmer). I got it to work and want to share it with the community.
This is a small custom Hubitat driver I built for controlling the weekly heating schedule on a Plugwise Adam / Smile OT gateway.
It lets you pause or resume Adam’s internal weekly rule (the same one you toggle from the Plugwise web UI).
Useful if you want to disable heating automatically when nobody is home, or resume it when presence or motion is detected.

:jigsaw: Plugwise Adam – Weekly Schedule Toggle Driver (Hubitat)

Hey everyone :wave:

This is a small custom Hubitat driver I built for controlling the weekly heating schedule on a Plugwise Adam / Smile OT gateway.
It lets you pause or resume Adam’s internal weekly rule (the same one you toggle from the Plugwise web UI).
Useful if you want to disable heating automatically when nobody is home, or resume it when presence or motion is detected.


:wrench: Background

Some older Adam / Smile OT firmwares still expose their REST API at
http://<adam-ip>/core/rules;id=<rule-id>

Toggling a weekly schedule in the Plugwise UI sends a PUT request with XML like this:

<rules>
  <rule id="...">
    <active>true</active>
  </rule>
</rules>

This driver reproduces that call from Hubitat, so you can integrate it with HSM, Rule Machine, or motion automations.


:warning: Known behaviour

  • Certain Adam firmwares return HTTP 500 for these PUT requests (internal Lua Zigbee handler crash).
    Despite the 500, the rule does get updated correctly.
    The driver logs that as a warning but continues.
  • The <template .../> element must not be included in the XML; otherwise you’ll get a “XML validation failed” error.
  • The XML must be sent in one line, or Adam will sometimes reject it.

:brain: Typical use cases

  • Pause weekly heating schedule when HSM is Armed Away
  • Resume when HSM is Disarmed
  • Pause after X minutes of no motion (using a virtual presence switch)

:computer: Driver code

/**
 * Plugwise Adam - Weekly Rule Toggle
 *
 * Purpose
 * -------
 * Lets Hubitat control the Plugwise Adam's internal "weekly rule" via its REST API.
 * It can pause or resume the rule (same effect as toggling the schedule in the UI).
 *
 * Notes
 * -----
 * - Some Adam firmwares return HTTP 500 even when the update succeeds.
 * - Do not include <template .../> in the XML body.
 * - Keep XML on one line.
 *
 * Author: anonymous / community
 * Version: 2025-10-31
 */
metadata {
    definition(name: "Plugwise Adam - Weekly Toggle", namespace: "community", author: "anonymous") {
        capability "Actuator"
        capability "Initialize"
        command "pauseWeekly",  [[name:"Pause weekly schedule"]]
        command "resumeWeekly", [[name:"Resume weekly schedule"]]
    }
}

preferences {
    input name: "adamIp",   type: "text",     title: "Plugwise Adam IP / hostname", required: true
    input name: "adamUser", type: "text",     title: "Username",                    required: true, defaultValue: "smile"
    input name: "adamPass", type: "password", title: "Password",                    required: true
    input name: "ruleId",   type: "text",     title: "Rule ID (weekly)",            required: true
    input name: "zoneId",   type: "text",     title: "Zone/Location ID",            required: true
    input name: "logEnable",type: "bool",     title: "Enable debug logging?",       defaultValue: true
}

def installed()  { initialize() }
def updated()    { initialize() }
def initialize() {
    if (logEnable) log.info "Plugwise Adam - Weekly Toggle initialised"
}

/**
 * Pause = deactivate weekly schedule
 */
def pauseWeekly() {
    setWeeklyActive(false)
}

/**
 * Resume = activate weekly schedule
 */
def resumeWeekly() {
    setWeeklyActive(true)
}

/**
 * Builds the XML payload and sends the PUT to Adam.
 */
private setWeeklyActive(Boolean activeFlag) {
    if (!adamIp || !adamUser || !adamPass || !ruleId || !zoneId) {
        log.warn "Missing required preferences (IP, user, pass, ruleId, zoneId)"
        return
    }

    def url = "http://${adamIp}/core/rules;id=${ruleId}"

    // One-line XML, no <template> element allowed
    String xml = """<rules><rule id="${ruleId}"><name>weekly</name><description/><active>${activeFlag}</active><directives><when time="[mo 18:00,mo 23:00)"><then preset="home"/></when><when time="[mo 23:00,tu 18:00)"><then preset="asleep"/></when><when time="[tu 18:00,tu 23:00)"><then preset="home"/></when><when time="[tu 23:00,we 18:00)"><then preset="asleep"/></when><when time="[we 18:00,we 23:00)"><then preset="home"/></when><when time="[we 23:00,th 18:00)"><then preset="asleep"/></when><when time="[th 18:00,th 23:00)"><then preset="home"/></when><when time="[th 23:00,fr 18:00)"><then preset="asleep"/></when><when time="[fr 18:00,fr 23:00)"><then preset="home"/></when><when time="[fr 23:00,sa 09:00)"><then preset="asleep"/></when><when time="[sa 09:00,sa 23:45)"><then preset="home"/></when><when time="[sa 23:45,su 09:00)"><then preset="asleep"/></when><when time="[su 09:00,su 23:45)"><then preset="home"/></when><when time="[su 23:45,mo 18:00)"><then preset="asleep"/></when></directives><contexts><context><zone><location id="${zoneId}"/></zone></context></contexts></rule></rules>"""

    if (logEnable) log.info "Sending Adam weekly -> ${activeFlag ? 'ON' : 'OFF'} to ${url}"

    Map headers = [
        "Content-Type": "application/xml",
        "Accept"      : "*/*",
    ]
    String basic = "${adamUser}:${adamPass}".bytes.encodeBase64().toString()
    headers["Authorization"] = "Basic ${basic}"

    def params = [
        uri: url,
        headers: headers,
        body: xml,
        contentType: "application/xml",
        requestContentType: "application/xml"
    ]

    try {
        httpPut(params) { resp ->
            if (logEnable) {
                log.info "Adam response status: ${resp.status}"
                try {
                    log.debug "Adam response body: ${resp.data}"
                } catch (ignored) { }
            }
        }
    } catch (e) {
        // Known issue: Adam may throw HTTP 500 but still apply the change
        log.warn "Adam returned error (likely still applied): ${e.message}"
    }
}

:jigsaw: Example usage in Rule Machine

Trigger: HSM status changes
Actions:

IF (HSM status = Armed Away) THEN
    Plugwise Adam - Weekly Toggle → pauseWeekly()
ELSE
    Plugwise Adam - Weekly Toggle → resumeWeekly()
END-IF

Or use a virtual presence / motion-based automation to save gas by pausing the schedule when nobody’s home.


:brain: Tested on

  • Plugwise Adam / Smile OT (firmware 3.9.x)
  • Hubitat C-7
  • Works even when Adam returns HTTP 500; rule updates successfully.

That’s it!
If anyone else still runs an Adam locally and wants to integrate it with Hubitat or Node-RED, this should save you some trial-and-error.
Feedback or improvements are welcome :point_down:

new improved version!

:jigsaw: Description

A local Hubitat driver for the Plugwise Adam Home Automation Gateway.
It allows full control and monitoring without cloud dependency, including:

  • Reading current temperature, humidity, and heating setpoint
  • Managing the weekly schedule (enable/disable)
  • Switching presets (Home / Away / Asleep)
  • Setting a manual heating setpoint directly from Hubitat
  • Showing gas consumption and monthly cost, including fixed daily fees

Designed for privacy-friendly, LAN-only operation.
All logic runs inside Hubitat; no external integrations required.

:rocket: Installation

  1. Copy the code below into Drivers Code → New Driver in Hubitat.
  2. Save and create a new device using this driver.
  3. In Device Preferences, fill in:
  • your Adam hostname/IP (no protocol required, e.g. 192.168.1.70)
  • the Adam credentials (smile + local password)
  • the Weekly Rule ID and Location ID from your own Adam (xml output from api)
  1. Click Save Preferences, then Refresh.

:abacus: Features

Function Description
Temperature / Humidity Reads live sensor data from your Adam zone
Weekly Schedule Toggle Pause or resume the weekly heating plan
Presets Home / Away / Asleep directly from Hubitat
Setpoint Override temperature manually
Gas Monitoring Shows daily + monthly m³ and cost
Custom Pricing Enter your own €/m³ and fixed daily costs
Dashboard Support Works with standard “Thermostat” and “Temperature” tiles
/*
 * Plugwise Adam - LAN Thermostat + Weekly + Preset + Gas Monitoring (€)
 * Community version (v5)
 *
 * Author: anonymous community build (2025)
 * Platform: Hubitat Elevation
 * License: MIT
 *
 * Features:
 *  - Local control of Plugwise Adam via REST XML API
 *  - Temperature, humidity, heating setpoint
 *  - Weekly schedule on/off
 *  - Presets (home / away / asleep)
 *  - Manual setpoint override
 *  - Gas usage (today + month)
 *  - Monthly cost calculation (variable + fixed)
 *
 * No external dependencies or cloud required.
 */

metadata {
    definition(name: "Plugwise Adam LAN Thermostat", namespace: "community", author: "anon") {
        capability "Initialize"
        capability "Refresh"
        capability "TemperatureMeasurement"
        capability "RelativeHumidityMeasurement"
        capability "Thermostat"
        capability "Actuator"
        capability "Sensor"

        attribute "adamPreset", "string"
        attribute "adamWeeklyActive", "string"
        attribute "adamScheduleName", "string"

        attribute "gasTodayM3", "number"
        attribute "gasMonthM3", "number"
        attribute "gasCostVariableMonth", "number"
        attribute "gasCostFixedMonth", "number"
        attribute "gasCostMonth", "number"

        command "toggleWeekly"
        command "setPresetHome"
        command "setPresetAway"
        command "setPresetAsleep"
        command "homeMode"
        command "awayMode"
    }

    preferences {
        input name: "adamHost", type: "string", title: "Adam host/IP", required: true
        input name: "adamPort", type: "number", title: "Port", required: true, defaultValue: 80
        input name: "adamUser", type: "string", title: "Username", required: true, defaultValue: "smile"
        input name: "adamPass", type: "password", title: "Password (ID)", required: true
        input name: "weeklyRuleId", type: "string", title: "Weekly rule id", required: true
        input name: "zoneLocationId", type: "string", title: "Zone location id", required: true
        input name: "zoneName", type: "string", title: "Zone name", defaultValue: "Living room"
        input name: "zoneType", type: "string", title: "Zone type", defaultValue: "livingroom"

        input name: "gasPrice", type: "decimal", title: "Gas price per m³ (€ incl. tax)", defaultValue: 1.18919
        input name: "gasDailySupplier", type: "decimal", title: "Daily supplier cost (€)", defaultValue: 0.26922
        input name: "gasDailyGrid", type: "decimal", title: "Daily grid cost (€)", defaultValue: 0.65606
        input name: "gasFactor", type: "decimal", title: "Gas unit factor (1 = m³)", defaultValue: 1.0

        input name: "debugLogging", type: "bool", title: "Enable debug logging", defaultValue: true
    }
}

def installed() { initialize() }
def updated()  { initialize() }

def initialize() {
    logInfo "Initializing Plugwise Adam LAN Thermostat"
    refresh()
}

/* =========================================================
 * HIGH-LEVEL CONTROL
 * ========================================================= */

def homeMode() {
    setWeekly(true)
    setZonePreset("home")
    runIn(2, "refresh")
}

def awayMode() {
    setWeekly(false)
    setZonePreset("away")
    runIn(2, "refresh")
}

/* =========================================================
 * REFRESH
 * ========================================================= */

def refresh() {
    doAdamGet("/core/domain_objects", "handleDomainObjects")
}

/* =========================================================
 * WEEKLY SCHEDULE
 * ========================================================= */

def toggleWeekly(Boolean turnOn = null) {
    def active = turnOn != null ? turnOn : (device.currentValue("adamWeeklyActive") != "on")
    setWeekly(active)
}

def setWeekly(Boolean active) {
    if (!weeklyRuleId) { logWarn "No weeklyRuleId configured"; return }
    def onOff = active ? "true" : "false"
    def body = """
<rules>
  <rule id="${weeklyRuleId}">
    <active>${onOff}</active>
  </rule>
</rules>
"""
    def path = "/core/rules;id=${weeklyRuleId}"
    logInfo "Setting Adam weekly schedule to ${active ? 'ON' : 'OFF'}"
    doAdamPut(path, body, "handleWeeklyPut")
}

/* =========================================================
 * PRESETS
 * ========================================================= */

def setPresetHome()   { setZonePreset("home") }
def setPresetAway()   { setZonePreset("away") }
def setPresetAsleep() { setZonePreset("asleep") }

private void setZonePreset(String preset) {
    if (!zoneLocationId) { logWarn "No zoneLocationId configured"; return }
    def body = """
<locations>
  <location id="${zoneLocationId}">
    <name>${xmlEscape(zoneName ?: "Living room")}</name>
    <type>${xmlEscape(zoneType ?: "livingroom")}</type>
    <preset>${xmlEscape(preset)}</preset>
  </location>
</locations>
"""
    def path = "/core/locations;id=${zoneLocationId}"
    logInfo "Setting preset -> ${preset}"
    doAdamPut(path, body, "handlePresetPut")
}

/* =========================================================
 * SETPOINT
 * ========================================================= */

def setHeatingSetpoint(temp) {
    def t = temp as BigDecimal
    t = t.setScale(1, BigDecimal.ROUND_HALF_UP)
    logInfo "Setting heating setpoint to ${t} °C"
    sendEvent(name: "heatingSetpoint", value: t as Double, unit: "°C")
    sendSetpointVariant1(t)
}

private void sendSetpointVariant1(BigDecimal t) {
    def currentPreset = device.currentValue("adamPreset") ?: "home"
    def body = """
<locations>
  <location id="${zoneLocationId}">
    <name>${xmlEscape(zoneName ?: "Living room")}</name>
    <type>${xmlEscape(zoneType ?: "livingroom")}</type>
    <preset>${xmlEscape(currentPreset)}</preset>
    <thermostat>${t}</thermostat>
  </location>
</locations>
"""
    def path = "/core/locations;id=${zoneLocationId}"
    doAdamPut(path, body, "handleSetpointV1")
}

/* =========================================================
 * HTTP HELPERS
 * ========================================================= */

private doAdamGet(String path, String callbackMethod) {
    def params = [ uri: normBaseUrl(), path: path, headers: basicHeaders() ]
    if (debugLogging) log.debug "GET ${params.uri}${params.path}"
    asynchttpGet(callbackMethod, params)
}

private doAdamPut(String path, String body, String callbackMethod) {
    def params = [
        uri: normBaseUrl(),
        path: path,
        headers: basicHeaders(true),
        body: body,
        contentType: "text/xml"
    ]
    if (debugLogging) {
        log.debug "PUT ${params.uri}${params.path}"
        log.debug "BODY: ${body}"
    }
    asynchttpPut(callbackMethod, params)
}

private String normBaseUrl() {
    def host = (adamHost ?: "").trim()
    def port = (adamPort ?: 80) as int
    host = host.replaceAll(/^https?:\/\//, "").replaceAll("/+\$", "")
    return "http://${host}:${port}"
}

private Map basicHeaders(Boolean isPut = false) {
    def auth = "${adamUser}:${adamPass}".bytes.encodeBase64().toString()
    def h = ["Authorization": "Basic ${auth}", "Accept": "text/xml"]
    if (isPut) h."Content-Type" = "application/xml"
    return h
}

/* =========================================================
 * CALLBACKS
 * ========================================================= */

def handleDomainObjects(resp, data) {
    if (resp.hasError()) { logWarn "GET failed: ${resp.getStatus()} - ${resp.getErrorMessage()}"; return }

    def xml = new XmlSlurper().parseText(resp.getData())
    def weekly = xml.rule.find { it.@id.text() == weeklyRuleId }
    if (weekly) {
        def active = weekly.active.text() == "true"
        sendEvent(name: "adamWeeklyActive", value: active ? "on" : "off")
        sendEvent(name: "adamScheduleName", value: weekly.name.text())
    }

    def loc = xml.location.find { it.@id.text() == zoneLocationId }
    if (loc) {
        def preset = loc.preset.text()
        if (preset) sendEvent(name: "adamPreset", value: preset)

        def tempLog = loc.logs.'point_log'.find { it.type.text() == "temperature" }
        if (tempLog) sendEvent(name: "temperature", value: tempLog.period.measurement.text() as Double, unit: "°C")

        def humLog = loc.logs.'point_log'.find { it.type.text() == "humidity" }
        if (humLog) sendEvent(name: "humidity", value: humLog.period.measurement.text() as Double, unit: "%")

        def spLog = loc.logs.'point_log'.find { it.type.text() == "thermostat" }
        if (spLog) sendEvent(name: "heatingSetpoint", value: spLog.period.measurement.text() as Double, unit: "°C")
    }

    parseGasUsage(xml)
}

def handleWeeklyPut(resp, data) { if (!resp.hasError()) refresh() else logWarn "Weekly PUT failed: ${resp.getStatus()}" }
def handlePresetPut(resp, data) { if (!resp.hasError()) refresh() else logWarn "Preset PUT failed: ${resp.getStatus()}" }
def handleSetpointV1(resp, data) { if (!resp.hasError()) refresh() else logWarn "Setpoint PUT failed: ${resp.getStatus()}" }

/* =========================================================
 * GAS MONITORING
 * ========================================================= */

private void parseGasUsage(def xml) {
    def logs = xml.'**'.findAll { n -> n.name() == 'point_log' && n.@type?.text()?.toLowerCase()?.contains('gas') }
    def now = new Date()
    def monthStr = now.format("yyyy-MM")
    def dayStr   = now.format("yyyy-MM-dd")

    BigDecimal monthTotal = 0
    BigDecimal todayTotal = 0

    logs.each { g ->
        def val = g.period?.measurement?.text()
        if (!val) return
        def v = val as BigDecimal
        def start = g.period?.@start_date?.text() ?: g.period?.start?.text() ?: ""
        if (start.startsWith(monthStr)) monthTotal += v
        if (start.startsWith(dayStr)) todayTotal += v
    }

    BigDecimal factor = (settings.gasFactor ?: 1.0) as BigDecimal
    monthTotal *= factor
    todayTotal *= factor

    BigDecimal price = (settings.gasPrice ?: 1.18919) as BigDecimal
    BigDecimal varCost = (monthTotal * price).setScale(2, BigDecimal.ROUND_HALF_UP)
    BigDecimal fixedCost = ((settings.gasDailySupplier ?: 0) + (settings.gasDailyGrid ?: 0)) * (now.format("d") as int)
    fixedCost = fixedCost.setScale(2, BigDecimal.ROUND_HALF_UP)
    BigDecimal total = (varCost + fixedCost).setScale(2, BigDecimal.ROUND_HALF_UP)

    sendEvent(name: "gasTodayM3", value: todayTotal as Double, unit: "m³")
    sendEvent(name: "gasMonthM3", value: monthTotal as Double, unit: "m³")
    sendEvent(name: "gasCostVariableMonth", value: varCost as Double, unit: "€")
    sendEvent(name: "gasCostFixedMonth", value: fixedCost as Double, unit: "€")
    sendEvent(name: "gasCostMonth", value: total as Double, unit: "€")
}

/* =========================================================
 * LOGGING
 * ========================================================= */

private logDebug(msg) { if (debugLogging) log.debug msg }
private logInfo(msg)  { log.info msg }
private logWarn(msg)  { log.warn msg }

private static String xmlEscape(String s) {
    if (!s) return ""
    s.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
     .replaceAll(">", "&gt;").replaceAll("\"", "&quot;")
     .replaceAll("'", "&apos;")
}