/*
* Grizzl-E Charger
*
* Description:
* This Hubitat driver polls a Grizzl-E charger.
*
* 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 2025 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
*/
// Returns the driver name
def DriverName(){
return "Grizzl-ECharger"
}
// Returns the driver version
def DriverVersion(){
return "0.1.0"
}
metadata{
definition ( name: "Grizzl-ECharger", namespace: "Snell", author: "David Snell", importUrl: "https://www.drdsnell.com/projects/hubitat/drivers/Grizzl-ECharger.groovy" ) {
// Attempting to indicate what capabilities the device should be capable of
capability "Sensor"
capability "Refresh"
capability "PowerMeter"
capability "CurrentMeter"
capability "VoltageMeasurement"
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
attribute "SerialNumber", "string" // Serial number of the charger
attribute "MainFirmware", "string" // Current version of the main system firmware
attribute "WiFiFirmware", "string" // Current version of the WiFi firmware
attribute "LifetimePower", "number" // Lifetime power usage of charger (in kWh)
attribute "MaxCurrent", "number" // Maximum current charger will allow (in A)
attribute "Mode", "string" // Charging mode the charger is set for
attribute "LastUpdated", "string" // Data as of
// Session-specific attributes
attribute "SessionPower", "number" // Current charging session's power usage (in kWh)
attribute "SessionDuration", "number" // Current charging session's duration (in seconds)
attribute "SessionDurationString", "string" // Current charging session's duration (in human-readable time format)
// 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]Power:[/b] @power@[/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() }", null, true )
ProcessEvent( "DriverVersion", "${ DriverVersion() }", null, true )
ProcessEvent( "DriverStatus", null, null, true )
// 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 Grizzl-E @ ${ DeviceIP }", 4 )
def Params = Params = [ uri: "http://${ DeviceIP }/main", contentType: "application/json" ]
asynchttpPost( "ProcessData", Params )
}
// Handles the response from Grizzl-E
def ProcessData( resp, data ) {
if( resp.getStatus() == 200 ) {
Logging( "Raw Response = ${ resp.data }", 4 )
Json = parseJson( resp.data )
ParseJSON( Json )
} else if( resp.getStatus() == 401 ) {
Logging( "Error connecting: ${ resp.status }", 5 )
}
}
// Parse the data returned, output events and populate attributes as needed
def ParseJSON( Json ){
Json.each(){
switch( it.key ){
case "serialNum":
ProcessEvent( "SerialNumber", it.value )
break
case "verFWMain":
ProcessEvent( "MainFirmware", it.value )
break
case "verFWWifi":
ProcessEvent( "WiFiFirmware", it.value )
break
case "currentSet":
ProcessEvent( "MaxCurrent", it.value, "A" )
break
case "totalEnergy":
ProcessEvent( "LifetimePower", it.value, "kWh" )
break
case "voltMeas1":
ProcessEvent( "voltage", it.value, "V" )
break
case "curMeas1":
ProcessEvent( "amperage", it.value, "A" )
break
case "temperature1":
ProcessEvent( "temperature", ConvertTemperature( "C", it.value ), location.getTemperatureScale() )
break
case "sessionEnergy":
ProcessEvent( "SessionPower", it.value, "kWh" )
break
case "sessionTime":
ProcessEvent( "SessionDuration", it.value, "seconds" )
def TempString = "${ Math.round( it.value / 3600 ) } hours"
TempString += ", ${ Math.round( ( it.value % 3600 ) / 60 ) } minutes"
TempString += ", ${ Math.round( ( it.value % 60 ) / 60 ) } seconds"
ProcessEvent( "SessionDurationString", TempString )
break
case "systemTime":
ProcessEvent( "LastUpdated", ConvertEpochToDate( "${ it.value }" ) )
break
case "state":
case "sh2EnergyValue":
case "gridRange":
case "sh2EnergyEnable":
case "sh1Enabled":
case "sh2Enabled":
case "curMeas2":
case "curMeas3":
case "evseEnabled":
case "ocppOfflineAva":
case "tarif":
case "tarifAStop":
case "suspendErrors":
case "broadcastMode":
case "tarifAStart":
case "IEM1_money":
case "sh1Stop":
case "adapter":
case "aiVoltage":
case "verFWStatus":
case "timeZone":
case "moneyLimitS":
case "sh2Start":
case "ocppVendor":
case "tarifBStart":
case "suspendLimits":
case "powerMeas":
case "timerType":
case "fwCRC32":
case "typeRelay":
case "energyLimitS":
case "scanComplete":
case "sh1Start":
case "subState":
case "temperature2":
case "SNflag":
case "tarifBEnable":
case "aiVoltageStart":
case "tarifAValue":
case "tarifAEnable":
case "sh1CurrentEnable":
case "sh1EnergyEnable":
case "ocppEnabled":
case "IEM1":
case "IEM2":
case "sh1CurrentValue":
case "voltMeas3":
case "sh2CurrentValue":
case "sessionStarted":
case "vBat":
case "voltMeas2":
case "energyLimit":
case "ocppconnected":
case "timeLimitS":
case "groundCtrl":
case "serialNumCPU":
case "delayedLimit":
case "curDesign":
case "minVoltage":
case "activeTarif":
case "typeEvse":
case "moneyLimit":
case "ground":
case "minCurrent":
case "leakValue":
case "oneCharge":
case "IEM2_money":
case "aiStatus":
case "aiModecurrent":
case "sh2CurrentEnable":
case "sessionMoney":
case "sh2Stop":
case "timeMsg":
case "lang":
case "aiPowerDrop":
case "tarifBValue":
case "aiVoltageDrop":
case "pilot":
case "tarifBStop":
case "leakValueH":
case "timeLimit":
case "sh1EnergyValue":
case "logReady":
case "switchState":
Logging( "Unknown use, not using: ${ it.key } = ${ it.value }", 4 )
break
case "STA_IP_Addres": // Charger's IP address, required for the driver to work in the first place
case "RSSI": // Strength of the WiFi signal the charger is receiving
case "stationId": // Duplicates serial number
Logging( "Known use, duplicate or not relevant: ${ it.key } = ${ it.value }", 4 )
break
default:
Logging( "Unhandled data ${ it.key } = ${ it.value }", 3 )
break
}
}
}
// 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()
}
//def date = use( groovy.time.TimeCategory ){
// new Date( 0 ) + Temp
//}
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 data to check against current state value and then send an event if it has changed
def ProcessEvent( Variable, Value, Unit = null, Description = null ){
sendEvent( name: Variable, value: Value, unit: Unit, descriptionText: Description )
def TempString = "Event: ${ Variable } = ${ Value }"
if( Unit != null ){
TempString += " Unit = ${ Unit }"
}
if( Description != null ){
TempString += " Description = ${ Description }"
}
Logging( "${ TempString }", 4 )
ProcessState( Variable, Value )
}
// Set a state variable to a value
def ProcessState( Variable, Value ){
state."${ Variable }" = Value
Logging( "State: ${ Variable } = ${ Value }", 4 )
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", DriverVersion() )
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 = DriverVersion().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
}
}
}