/* * 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 } } }