/*
* Climate Master Local API
*
* Description:
* This Hubitat driver polls a Climate Master device's local API.
*
* Features List:
* Capability to have a Tile Template for displaying multiple variables together
*
* Instructions for using Tile method:
* 1) In "Preferences -> Tile Template" enter your template (example below) and click "Save Preferences"
* Ex: "[b]Temperature:[/b] @temperature@°@location.getTemperatureScale()@[/br]"
* 2) In a Hubitat dashboard, add a new tile, and select the child/sensor, in the center select "Attribute", and on the right select the "Tile" attribute
* 3) Select the Add Tile button and the tile should appear
* NOTE1: Put a @ before and after variable names
* NOTE2: Should accept most HTML formatting commands with [] instead of <>
*
* Licensing:
* Copyright 2026 David Snell
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at: http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
* for the specific language governing permissions and limitations under the License.
*
* Version Control:
* 0.1.0 - Initial Version
*
* Thank you(s):
* @Cobra's contributions to the community, the driver update notice is based on his original method
* @mircolino for working out the HTML Template method for dashboard use
*/
import groovy.transform.Field
@Field static final String DRIVER = "ClimateMasterLocalAPI"
// Returns the driver name
def DriverName(){
return "ClimateMasterLocalAPI"
}
@Field static final String VERSION = "0.1.0"
// Returns the driver version
public static String version(){
return "0.1.0"
}
metadata{
definition ( name: "ClimateMasterLocalAPI", namespace: "Snell", author: "David Snell", importUrl: "https://www.drdsnell.com/projects/hubitat/drivers/ClimateMasterLocalAPI.groovy" ) {
// Attempting to indicate what capabilities the device should be capable of
capability "Sensor"
capability "Refresh"
capability "TemperatureMeasurement"
//command "DoSomething"
// Attributes
// Driver-specific attributes
attribute "DriverName", "string" // Driver identifies the driver being used for update purposes
attribute "DriverVersion", "string" // Version number of the driver
attribute "DriverStatus", "string" // Status of the driver version compared to what is currently published
// Device-specific attributes
// Session-specific attributes
// Tile Template attribute
attribute "Tile", "string"; // Ex: "[b]Power:[/b] @power@[/br]"
}
preferences{
section{
input( name: "TileTemplate", type: "string", title: "Tile Template", description: "Ex: [b]Temperature:[/b] @temperature@[/br]", defaultValue: "");
input( type: "enum", name: "RefreshRate", title: "Refresh Rate", required: false, multiple: false, options: [ "1 minute", "5 minutes", "10 minutes", "15 minutes", "30 minutes", "1 hour", "3 hours", "Manual" ], defaultValue: "5 minutes" )
input( type: "string", name: "DeviceIP", title: "IPv4/Hostname Address", required: true )
input( type: "enum", name: "LogType", title: "Enable Logging?", required: true, multiple: false, options: [ "None", "Info", "Debug", "Trace" ], defaultValue: "Info" )
}
}
}
// DoSomething is meant for pre-release testing purposes and should the command should be commented before publishing
def DoSomething(){
}
// updated is called whenever device parameters are saved
// It sets the current version of the driver and sets some basic settings
def updated(){
Logging( "Updated", 2 )
if( LogType == null ){
LogType = "Info"
}
ProcessEvent( "DriverName", DriverName() )
ProcessEvent( "DriverVersion", version() )
ProcessEvent( "DriverStatus" )
// Check what the refresh rate is set for then run it
switch( RefreshRate ){
case "1 minute":
runEvery1Minute( "refresh" )
break
case "5 minutes":
runEvery5Minutes( "refresh" )
break
case "10 minutes":
runEvery10Minutes( "refresh" )
break
case "15 minutes":
runEvery15Minutes( "refresh" )
break
case "30 minutes":
runEvery30Minutes( "refresh" )
break
case "1 hour":
runEvery1Hour( "refresh" )
break
case "3 hours":
runEvery3Hours( "refresh" )
break
case "Manual":
unschedule( "refresh" )
break
}
Logging( "Refresh rate: ${ RefreshRate }", 3 )
schedule( new Date(), CheckForUpdate )
}
// refresh triggers a poll for data
def refresh(){
Poll()
}
//Poll for data
def Poll() {
Logging( "Polling data from Climate Master @ ${ DeviceIP }", 4 )
def Params = [ uri: "http://${ DeviceIP }/query/info", contentType: "application/json" ]
asynchttpPost( "ProcessData", Params, [ Method: "QueryInfo" ] )
Params = [ uri: "http://${ DeviceIP }/query/sensor", contentType: "application/json" ]
asynchttpPost( "ProcessData", Params, [ Method: "QuerySensor" ] )
Params = [ uri: "http://${ DeviceIP }/query/sys", contentType: "application/json" ]
asynchttpPost( "ProcessData", Params, [ Method: "QuerySys" ] )
}
// Handles the response
def ProcessData( resp, data ) {
if( resp.getStatus() == 200 ) {
Logging( "Raw Response = ${ resp.data }", 4 )
//Json = parseJson( resp.data )
} else if( resp.getStatus() == 401 ) {
Logging( "Error connecting: ${ resp.status }", 5 )
}
}
// Used to convert epoch values to text dates
def String ConvertEpochToDate( String Epoch ){
Long Temp = Epoch.toLong()
def date
if( Temp <= 9999999999 ){
date = new Date( ( Temp * 1000 ) ).toString()
} else {
date = new Date( Temp ).toString()
}
return date
}
// Checks the location.getTemperatureScale() to convert temperature values
def ConvertTemperature( String Scale, Number Value ){
if( Value != null ){
def ReturnValue = Value as double
if( location.getTemperatureScale() == "C" && Scale.toUpperCase() == "F" ){
ReturnValue = ( ( ( Value - 32 ) * 5 ) / 9 )
} else if( location.getTemperatureScale() == "F" && Scale.toUpperCase() == "C" ) {
ReturnValue = ( ( ( Value * 9 ) / 5 ) + 32 )
} else if( location.getTemperatureScale() == Scale.toUpperCase() ){
ReturnValue = Value
}
def TempInt = ( ReturnValue * 100 ) as int
ReturnValue = ( TempInt / 100 )
return ReturnValue
}
}
// Tile method to produce HTML formatted string for dashboard use
private void UpdateTile( String val ){
if( TileTemplate != null ){
def TempString = ""
Parsing = TileTemplate
Parsing = Parsing.replaceAll( "\\[", "<" )
Parsing = Parsing.replaceAll( "\\]", ">" )
Count = Parsing.count( "@" )
if( Count >= 1 ){
def x = 1
while( x <= Count ){
TempName = Parsing.split( "@" )[ x ]
switch( TempName ){
case "location.latitude":
Value = location.latitude
break
case "location.longitude":
Value = location.longitude
break
case "location.getTemperatureScale()":
Value = location.getTemperatureScale()
break
default:
Value = ReturnState( "${ TempName }" )
break
}
TempString = TempString + Parsing.split( "@" )[ ( x - 1 ) ] + Value
x = ( x + 2 )
}
if( Parsing.split( "@" ).last() != Parsing.split( "@" )[ Count - 1 ] ){
TempString = TempString + Parsing.split( "@" ).last()
}
} else if( Count == 1 ){
TempName = Parsing.split( "@" )[ 1 ]
switch( TempName ){
case "location.latitude":
Value = location.latitude
break
case "location.longitude":
Value = location.longitude
break
case "location.getTemperatureScale()":
Value = location.getTemperatureScale()
break
default:
Value = ReturnState( "${ TempName }" )
break
}
TempString = TempString + Parsing.split( "@" )[ 0 ] + Value
} else {
TempString = TileTemplate
}
Logging( "Tile = ${ TempString }", 4 )
if( ReturnState( "Tile" ) != TempString ){
ProcessEvent( "Tile", TempString, null, true )
}
}
}
// Process an Event
def ProcessEvent( Variable, Value, Unit = null ){
ProcessState( "${ Variable }", Value )
if( Unit != null ){
Logging( "Event: ${ Variable } = ${ Value }${ Unit }", 4 )
sendEvent( name: "${ Variable }", value: Value, unit: Unit, isStateChange: true )
} else {
Logging( "Event: ${ Variable } = ${ Value }", 4 )
sendEvent( name: "${ Variable }", value: Value, isStateChange: true )
}
}
// Process a State Variable
def ProcessState( Variable, Value ){
Logging( "State: ${ Variable } = ${ Value }", 4 )
state."${ Variable }" = Value
UpdateTile( "${ Value }" )
}
// installed is called when the device is installed, all it really does is run updated
def installed(){
Logging( "Installed", 2 )
updated()
}
// Handles whether logging is enabled and thus what to put there.
def Logging( LogMessage, LogLevel ){
// Add all messages as info logging
if( ( LogLevel == 2 ) && ( LogType != "None" ) ){
log.info( "${ device.displayName } - ${ LogMessage }" )
} else if( ( LogLevel == 3 ) && ( ( LogType == "Debug" ) || ( LogType == "Trace" ) ) ){
log.debug( "${ device.displayName } - ${ LogMessage }" )
} else if( ( LogLevel == 4 ) && ( LogType == "Trace" ) ){
log.trace( "${ device.displayName } - ${ LogMessage }" )
} else if( LogLevel == 5 ){
log.error( "${ device.displayName } - ${ LogMessage }" )
}
}
// Checks drdsnell.com for the latest version of the driver
// Original inspiration from @cobra's version checking
def CheckForUpdate(){
ProcessEvent( "DriverName", DriverName() )
ProcessEvent( "DriverVersion", version() )
httpGet( uri: "https://www.drdsnell.com/projects/hubitat/drivers/versions.json", contentType: "application/json" ){ resp ->
switch( resp.status ){
case 200:
if( resp.data."${ DriverName() }" ){
CurrentVersion = version().split( /\./ )
if( resp.data."${ DriverName() }".version == "REPLACED" ){
ProcessEvent( "DriverStatus", "Driver replaced, please use ${ resp.data."${ state.DriverName }".file }" )
} else if( resp.data."${ DriverName() }".version == "REMOVED" ){
ProcessEvent( "DriverStatus", "Driver removed and no longer supported." )
} else {
SiteVersion = resp.data."${ DriverName() }".version.split( /\./ )
if( CurrentVersion == SiteVersion ){
Logging( "Driver version up to date", 3 )
ProcessEvent( "DriverStatus", "Up to date" )
} else if( ( CurrentVersion[ 0 ] as int ) > ( SiteVersion [ 0 ] as int ) ){
Logging( "Major development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version", 3 )
ProcessEvent( "DriverStatus", "Major development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version" )
} else if( ( CurrentVersion[ 1 ] as int ) > ( SiteVersion [ 1 ] as int ) ){
Logging( "Minor development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version", 3 )
ProcessEvent( "DriverStatus", "Minor development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version" )
} else if( ( CurrentVersion[ 2 ] as int ) > ( SiteVersion [ 2 ] as int ) ){
Logging( "Patch development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version", 3 )
ProcessEvent( "DriverStatus", "Patch development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version" )
} else if( ( SiteVersion[ 0 ] as int ) > ( CurrentVersion[ 0 ] as int ) ){
Logging( "New major release ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available", 2 )
ProcessEvent( "DriverStatus", "New major release ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available" )
} else if( ( SiteVersion[ 1 ] as int ) > ( CurrentVersion[ 1 ] as int ) ){
Logging( "New minor release ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available", 2 )
ProcessEvent( "DriverStatus", "New minor release ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available" )
} else if( ( SiteVersion[ 2 ] as int ) > ( CurrentVersion[ 2 ] as int ) ){
Logging( "New patch ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available", 2 )
ProcessEvent( "DriverStatus", "New patch ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available" )
}
}
} else {
Logging( "${ DriverName() } is not published on drdsnell.com", 2 )
ProcessEvent( "DriverStatus", "${ DriverName() } is not published on drdsnell.com" )
}
break
default:
Logging( "Unable to check drdsnell.com for ${ DriverName() } driver updates.", 2 )
break
}
}
}