/*
* Neurio
*
* Description:
* This Hubitat driver polls a Neurio home energy meter.
*
* Features List:
* Up to 6 channels
* Basic Authentication mode for Neurio
* Provides Power & Voltage for channels reported by the Neurio
* 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.7.2 - Changes to how Tile is handled, as well as event and state processing, change for amps calculation if amps are not provided
* 0.7.1 - Correction to ProcessEvent function, removal of old driver-specific attributes when Preferences are saved, other minor cleanup
* 0.7.0 - Major rework of various areas to match my current style of coding and data returned
* 0.6.5 - DataAsOf reformatted with 2 digit year
* 0.6.4 - Added "DataAsOf" attribute to know when data was last updated
* 0.6.3 - Updated method for processing state and event data, update to driver version checking methods
* 0.6.2 - Added HTML Template capability for dashboard use
* 0.6.1 - Hide password field
* 0.6.0 - Refresh of methods, newer style of code, correction to Refresh
* 0.5.0 - Corrected some copy/paste errors and updated to new refresh/version/logging methods to match my other drivers
* 0.4 - Update to driver version checking.
* 0.3 - Corrected how attributes are listed to reduce duplication in sent events
* 0.2 - Adding authentication method to handle Neurio Basic Authentication mode. Also added additional channel support.
* 0.1 - Based on my AmbientWeather driver.
*
* 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 "Neurio"
}
// Returns the driver version
def DriverVersion(){
return "0.7.2"
}
metadata{
definition ( name: "Neurio", namespace: "Snell", author: "David Snell", importUrl: "https://www.drdsnell.com/projects/hubitat/drivers/Neurio.groovy" ) {
// Attempting to indicate what capabilities the device should be capable of
capability "Sensor"
capability "Refresh"
capability "PowerMeter"
capability "CurrentMeter"
// Attributes being built into the device
// Driver identifies the driver being used for update purposes
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
attribute "Firmware", "string" // Firmware keeps track of the current version of the Neurio firmware
attribute "power", "number" // power is the CONSUMPTION channel's p_W value
attribute "amperage", "number" // amperage is the total of all the CTS i_A values reported
attribute "timestamp", "string" // timestamp is when the last reading was polled from the Neurio
attribute "DataAsOf", "string" // Last time Hubitat updated the data from the Neurio
// Direct matches for names
attribute "PHASE_A_CONSUMPTION_Voltage", "number"
attribute "PHASE_B_CONSUMPTION_Voltage", "number"
attribute "PHASE_A_CONSUMPTION_Power", "number"
attribute "PHASE_B_CONSUMPTION_Power", "number"
attribute "CONSUMPTION_Voltage", "number"
attribute "CONSUMPTION_Power", "number"
// Special ones based on what a Neurio can typically do
attribute "Channel1Power", "number"
attribute "Channel1Voltage", "number"
attribute "Channel1Type", "string"
attribute "Channel2Power", "number"
attribute "Channel2Voltage", "number"
attribute "Channel2Type", "string"
attribute "Channel3Power", "number"
attribute "Channel3Voltage", "number"
attribute "Channel3Type", "string"
// Special ones based on what a Neurio can sometimes do with additional sub-meters
attribute "Channel4Power", "number"
attribute "Channel4Voltage", "number"
attribute "Channel4Type", "string"
attribute "Channel5Power", "number"
attribute "Channel5Voltage", "number"
attribute "Channel5Type", "string"
attribute "Channel6Power", "number"
attribute "Channel6Voltage", "number"
attribute "Channel6Type", "string"
// 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: "NeurioIP", title: "IPv4/Hostname Address", required: true )
input( type: "string", name: "NeurioUsername", title: "Username", description: "Local Username for Neurio not cloud login", required: false )
input( type: "password", name: "NeurioPassword", title: "Password", description: "Local Password for Neurio not cloud login", required: false )
input( type: "enum", name: "LogType", title: "Enable Logging?", required: true, multiple: false, options: [ "None", "Info", "Debug", "Trace" ], defaultValue: "Info" )
}
}
}
// 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"
}
// Set the driver name and version before update checking is scheduled
if( state."Driver Status" != null ){
state.remove( "Driver Name" )
state.remove( "Driver Version" )
state.remove( "Driver Status" )
device.deleteCurrentState( "Driver Status" )
device.deleteCurrentState( "Driver Name" )
device.deleteCurrentState( "Driver Version" )
}
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(){
PollNeurio()
}
//Poll Neurio for data
def PollNeurio() {
Logging( "Attempting to get data from Neurio", 4 )
def JSONParams = null
if( NeurioUsername != null && NeurioPassword != null ){
JSONParams = [ uri: "http://${ NeurioUsername }:${ NeurioPassword }@${ NeurioIP }/current-sample", contentType: "application/json" ]
} else {
JSONParams = [ uri: "http://${ NeurioIP }/current-sample", contentType: "application/json" ]
}
asynchttpGet( "GetNeurio", JSONParams )
}
// Handles the response from Neurio
def GetNeurio( resp, data ) {
if( resp.getStatus() == 200 ) {
Logging( "Neurio Raw Response = ${ resp.data }", 4 )
Json = parseJson( resp.data )
ParseJSON( Json )
} else if( resp.getStatus() == 401 ) {
Logging( "Error connecting to Neurio: ${ resp.getStatus() }, this may be caused by the Neurio using Basic Authentication so you will need to enter you login credentials as Preferences.", 5 )
} else {
Logging( "Error connecting to Neurio: ${ resp.status }", 5 )
}
}
// Parse the data returned, output events and populate attributes as needed
def ParseJSON( Json ){
// If there are any values returned, process them
if( Json != null ){
def NumberOfValuesAll = Json.size() as Integer
ProcessEvent( "timestamp", Json.timestamp )
def Count = 1
Json.channels.each{
ProcessState( "Channel${ Count }Type", it.type )
ProcessEvent( "Channel${ Count }Voltage", it.v_V, "V" )
ProcessEvent( "Channel${ Count }Power", it.p_W, "W" )
switch( it.type ){
case "PHASE_A_CONSUMPTION":
ProcessEvent( "PHASE_A_CONSUMPTION_Voltage", it.v_V, "V" )
ProcessEvent( "PHASE_A_CONSUMPTION_Power", it.p_W, "W" )
break
case "PHASE_B_CONSUMPTION":
ProcessEvent( "PHASE_B_CONSUMPTION_Voltage", it.v_V, "V" )
ProcessEvent( "PHASE_B_CONSUMPTION_Power", it.p_W, "W" )
break
case "CONSUMPTION":
ProcessEvent( "CONSUMPTION_Voltage", it.v_V, "V" )
ProcessEvent( "CONSUMPTION_Power", it.p_W, "W" )
ProcessEvent( "power", it.p_W, "W", true )
break
case "SUBMETER":
break
}
if( it.label != null ){
ProcessState( "Channel${ Count }Label", it.label )
}
Count ++
}
def TempAmps = 0
Json.cts.each{
if( it.i_A != null ){
TempAmps = ( TempAmps + it.i_A )
} else {
TempAmps = ( TempAmps + ( it.p_W / it.v_V ) )
}
}
ProcessEvent( "amperage", TempAmps, "A", true )
ProcessEvent( "DataAsOf", "${ new Date().format( "MM/dd/yy HH:mm" ) }" )
Logging( "Updated as of: ${ new Date().format( "MM/dd/yy HH:mm" ) }", 4 )
} else {
Logging( "No data reported", 3 )
}
}
// Return a state value
def ReturnState( Variable ){
return state."${ Variable }"
}
// 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, ForceEvent = false, Description = null ){
if( ForceEvent ){
sendEvent( name: Variable, value: Value, unit: Unit, isStateChange: true, descriptionText: Description )
} else {
sendEvent( name: Variable, value: Value, unit: Unit, descriptionText: Description )
}
Logging( "Event: ${ Variable } = ${ Value } Unit = ${ Unit } Forced = ${ ForceEvent }", 4 )
ProcessState( Variable, Value )
UpdateTile( "${ Value }" )
}
// Set a state variable to a value
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", 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
}
}
}