/*
* APC SmartUPS Status Driver
*
* Copyright 2025, 2026 MHedish
* Licensed under the Apache License, Version 2.0
* https://www.apache.org/licenses/LICENSE-2.0
*
* https://paypal.me/MHedish
*
* Changelog:
* 1.0.0.0 -- Initial stable release; validated under sustained load and reboot recovery.
* 1.0.1.0 -- Updated Preferences documentation tile.
* 1.0.1.1 -- Enhanced handleUPSStatus() to properly normalize multi-token NMC strings (e.g., “Online, Smart Trim”) via improved regex boundaries and partial-match detection.
* 1.0.1.2 -- Added nextBatteryReplacement attribute; captures and normalizes NMC "Next Battery Replacement Date" from battery status telemetry.
* 1.0.1.3 -- Added wiringFault attribute detection in handleUPSStatus(); automatically emits true/false based on "Site Wiring Fault" presence in UPS status line.
* 1.0.1.4 -- Corrected emitEvent() and emitChangedEvent()
* 1.0.1.5 -- Changed asynchronous delay when stale state variable is detected to blocking/synchronous to allow lazy-flushed update to complete before forcing refresh().
* 1.0.2.0 -- Changed state.lastCommand to atomicState.lastCommand
* 1.0.2.1 -- Corrected variable in checkUPSClock()
* 1.0.2.2 -- Changed state.deferredCommand to atomicState.deferredCommand
* 1.0.2.3 -- Added transient currentCommand
* 1.0.2.4 -- Reverted
* 1.0.2.5 -- Moved to atomicState variables
* 1.0.2.6 -- Changed mutex for sendUPSCommand()
* 1.0.2.7 -- Added summary text attribute and logging
* 1.0.2.8 -- Reverted
* 1.0.2.9 -- Fixed infinite deferral loop after hub reboot; Improved transient-based deferral counter
* 1.0.2.10 -- Introduced scheduled watchdog and notification; sets connectStatus to 'watchdog' when triggered
* 1.0.2.11 -- Corrected refresh CRON cadence switching when UPS enters/leaves battery mode
* 1.0.2.12 -- Corrected safeTelnetConnect runIn() map; updated scheduleCheck() to guard against watchdog unscheduling
* 1.0.3.0 –– Version bump for public release
* 1.0.4.0 -- Added NUL (0x00) stripping in parse() to ensure compatibility with AP9641 (NMC3) Telnet CR/NULL/LF line framing.
*/
import groovy.transform.Field
import java.util.Collections
@Field static final String DRIVER_NAME = "APC SmartUPS Status"
@Field static final String DRIVER_VERSION = "1.0.4.0"
@Field static final String DRIVER_MODIFIED = "2026.02.24"
@Field static final Map transientContext = Collections.synchronizedMap([:])
/* ===============================
Metadata
=============================== */
metadata {
definition(
name: DRIVER_NAME,
namespace: "MHedish",
author: "Marc Hedish",
importUrl: "https://raw.githubusercontent.com/MHedish/Hubitat/refs/heads/main/Drivers/APC-SmartUPS/APC-SmartUPS-Status.groovy"
){
capability "Actuator"
capability "Battery"
capability "Initialize"
capability "PowerSource"
capability "Refresh"
capability "Temperature Measurement"
attribute "alarmCountCrit","number"
attribute "alarmCountInfo","number"
attribute "alarmCountWarn","number"
attribute "battery","number"
attribute "batteryVoltage","number"
attribute "connectStatus", "string"
attribute "deviceName","string"
attribute "driverInfo","string"
attribute "firmwareVersion","string"
attribute "inputFrequency","number"
attribute "inputVoltage","number"
attribute "lastCommand","string"
attribute "lastCommandResult","string"
attribute "lastSelfTestDate","string"
attribute "lastSelfTestResult","string"
attribute "lastTransferCause","string"
attribute "lastUpdate","string"
attribute "lowBattery","boolean"
attribute "manufactureDate","string"
attribute "model","string"
attribute "nextBatteryReplacement","string"
attribute "nextCheckMinutes","number"
attribute "nmcApplicationDate","string"
attribute "nmcApplicationName","string"
attribute "nmcApplicationVersion","string"
attribute "nmcBootMonitor","string"
attribute "nmcBootMonitorDate","string"
attribute "nmcBootMonitorVersion","string"
attribute "nmcHardwareRevision","string"
attribute "nmcMACAddress","string"
attribute "nmcManufactureDate","string"
attribute "nmcModel","string"
attribute "nmcOSDate","string"
attribute "nmcOSName","string"
attribute "nmcOSVersion","string"
attribute "nmcSerialNumber","string"
attribute "nmcStatus","string"
attribute "nmcStatusDesc","string"
attribute "nmcUptime","string"
attribute "outputCurrent","number"
attribute "outputEnergy","number"
attribute "outputFrequency","number"
attribute "outputVAPercent","number"
attribute "outputVoltage","number"
attribute "outputWatts","number"
attribute "outputWattsPercent","number"
attribute "runTimeCalibration","string"
attribute "runTimeHours","number"
attribute "runTimeMinutes","number"
attribute "serialNumber","string"
attribute "summaryText","string"
attribute "temperatureC","number"
attribute "temperatureF","number"
attribute "upsContact","string"
attribute "upsDateTime","string"
attribute "upsLocation","string"
attribute "upsStatus","string"
attribute "upsUptime","string"
attribute "wiringFault","string"
command "refresh"
command "disableDebugLoggingNow"
command "enableUPSControl"
command "disableUPSControl"
command "alarmTest"
command "selfTest"
command "upsOn"
command "upsOff"
command "reboot"
command "sleep"
command "toggleRunTimeCalibration"
command "setOutletGroup",[
[name:"outletGroup",description:"Outlet Group 1 or 2 ",type:"ENUM",constraints:["1","2"],required:true,default:"1"],
[name:"command",description:"Command to execute ",type:"ENUM",constraints:["Off","On","DelayOff","DelayOn","Reboot","DelayReboot","Shutdown","DelayShutdown","Cancel"],required:true],
[name:"seconds",description:"Delay in seconds ",type:"ENUM",constraints:["1","2","3","4","5","10","20","30","45","60","90","120","180","240","300","600"],required:true]
]
}
}
/* ===============================
Preferences
=============================== */
preferences {
input("docBlock","hidden",title:driverDocBlock())
input("upsIP","text",title:"Smart UPS (APC only) IP Address",required:true)
input("upsPort","integer",title:"Telnet Port",description:"Default 23",defaultValue:23,required:true)
input("Username","text",title:"Username for Login",required:true,defaultValue:"")
input("Password","password",title:"Password for Login",required:true,defaultValue:"")
input("useUpsNameForLabel","bool",title:"Use UPS name for Device Label",description:"Automatically update Hubitat device label to UPS-reported name.",defaultValue:false)
input("tempUnits","enum",title:"Temperature Attribute Unit",options:["F","C"],defaultValue:"F")
input("runTime","number",title:"Check interval for UPS status (minutes, 1–59)",description:"Default 5",defaultValue:5,range:"1..59",required:true)
input("runOffset", "number",title:"Check Interval Offset (minutes past the hour, 0–59)",defaultValue:0,range: "0..59",required:true)
input("runTimeOnBattery","number",title: "Check interval when on battery (minutes, 1–59)",defaultValue:2,range: "1..59",required:true)
input("autoShutdownHub","bool",title:"Shutdown Hubitat when UPS battery is low",description:"",defaultValue:true)
input("upsTZOffset","number",title:"UPS Time Zone Offset (minutes)",description:"Offset UPS-reported time from hub (-720 to +840). Default=0 for same TZ",defaultValue:0,range:"-720..840")
input("logEnable","bool",title:"Enable Debug Logging",description:"Auto-off after 30 minutes.",defaultValue:false)
input("logEvents","bool",title:"Log All Events",description:"",defaultValue:false)
}
/* ===============================
Utilities
=============================== */
private String driverInfoString(){return "${DRIVER_NAME} v${DRIVER_VERSION} (${DRIVER_MODIFIED})"}
private driverDocBlock(){return"
"}
private logDebug(msg){if(logEnable) log.debug "[${DRIVER_NAME}] $msg"}
private logInfo(msg) {if(logEvents) log.info "[${DRIVER_NAME}] $msg"}
private logWarn(msg) {log.warn "[${DRIVER_NAME}] $msg"}
private logError(msg){log.error"[${DRIVER_NAME}] $msg"}
private void emitLastUpdate(){def s=getTransient("sessionStart");def ms=s?(now()-s):0;def sec=(ms/1000).toDouble().round(3);emitChangedEvent("lastUpdate",new Date().format("MM/dd/yyyy h:mm:ss a"),"Data Capture Run Time = ${sec}s");clearTransient("sessionStart")}
private emitEvent(String n,def v,String d=null,String u=null,boolean f=false){sendEvent(name:n,value:v,unit:u,descriptionText:d,isStateChange:f);if(logEvents)logInfo"${d?"${n}=${v} (${d})":"${n}=${v}"}"}
private emitChangedEvent(String n,def v,String d=null,String u=null,boolean f=false){def o=device.currentValue(n);if(f||o?.toString()!=v?.toString()){sendEvent(name:n,value:v,unit:u,descriptionText:d,isStateChange:f);if(logEvents)logInfo"${d?"${n}=${v} (${d})":"${n}=${v}"}"}else logDebug"No change for ${n} (still ${o})"}
private updateConnectState(String newState){def old=device.currentValue("connectStatus");def last=getTransient("lastConnectState");if(old!=newState&&last!=newState){setTransient("lastConnectState",newState);emitChangedEvent("connectStatus",newState)}else{logDebug"updateConnectState(): no change (${old} → ${newState})"}}
private updateCommandState(String newCmd){def old=atomicState.lastCommand;atomicState.lastCommand=newCmd;if(old!=newCmd)logDebug"lastCommand = ${newCmd}"}
private def normalizeDateTime(String r){if(!r||r.trim()=="")return r;try{def m=r=~/^(\d{2})\/(\d{2})\/(\d{2})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?$/;if(m.matches()){def(mm,dd,yy,hh,mi,ss)=m[0][1..6];def y=(yy as int)<80?2000+(yy as int):1900+(yy as int);def f="${mm}/${dd}/${y}"+(hh?" ${hh}:${mi}:${ss?:'00'}":"");def d=Date.parse(hh?"MM/dd/yyyy HH:mm:ss":"MM/dd/yyyy",f);return hh?d.format("MM/dd/yyyy h:mm:ss a",location.timeZone):d.format("MM/dd/yyyy",location.timeZone)};for(fmt in["MM/dd/yyyy HH:mm:ss","MM/dd/yyyy h:mm:ss a","MM/dd/yyyy","yyyy-MM-dd","MMM dd yyyy HH:mm:ss"])try{def d=Date.parse(fmt,r);return(fmt.contains("HH")||fmt.contains("h:mm:ss"))?d.format("MM/dd/yyyy h:mm:ss a",location.timeZone):d.format("MM/dd/yyyy",location.timeZone)}catch(e){} }catch(e){};return r}
private void initTelnetBuffer(){def b=getTransient("telnetBuffer");if(b instanceof List&&b.size()){def t;try{def p=b[-(Math.min(3,b.size()))..-1]*.line.findAll{it}.join(" | ");t=p[-(Math.min(80,p.size()))..-1]}catch(e){t="unavailable (${e.message})"};logDebug"initTelnetBuffer(): clearing leftover buffer (${b.size()} lines, preview='${t}')"};setTransient("telnetBuffer",[]);setTransient("sessionStart",now());logDebug"initTelnetBuffer(): Session start at ${new Date(getTransient('sessionStart'))}"}
private checkExternalUPSControlChange(){def c=device.currentValue("upsControlEnabled")as Boolean;def p=state.lastUpsControlEnabled as Boolean;if(p==null){state.lastUpsControlEnabled=c;return};if(c!=p){logInfo "UPS Control state changed externally (${p} → ${c})";state.lastUpsControlEnabled=c;updateUPSControlState(c);unschedule(autoDisableUPSControl);if(c)runIn(1800,"autoDisableUPSControl")else state.remove("controlDeviceName")}}
/* ==================================
NMC Status & UPS Error Translations
================================== */
private String translateNmcStatus(String statVal){
def t=[];statVal.split(" ").each{c->
switch(c){
case"P+":t<<"OS OK";break
case"P-":t<<"OS Error";break
case"N+":t<<"Network OK";break
case"N-":t<<"No Network";break
case"N4+":t<<"IPv4 OK";break
case"N6+":t<<"IPv6 OK";break
case"N?":t<<"Network DHCP/BOOTP pending";break
case"N!":t<<"IP Conflict";break
case"A+":t<<"App OK";break
case"A-":t<<"App Bad Checksum";break
case"A?":t<<"App Initializing";break
case"A!":t<<"App Incompatible";break
default:t<settings.runTime)logWarn"Configuration anomaly: Check interval when on battery exceeds nominal check interval."
if(thresholdinterval*2){def adjOff=(nowMin-(nowMin%interval));logInfo"scheduleCheck(): offset ${offset} too far ahead (${nowMin}m now); adjusting to ${adjOff} for current hour";offset=adjOff}
def wdOffset=offset+1;if(wdOffset>59)wdOffset=0
def cron7="0 ${offset}/${interval} * ? * * *";def cron6="0 ${offset}/${interval} * * * ?";def usedCron=null
try{schedule(cron7,refresh);usedCron=cron7}catch(ex){try{schedule(cron6,refresh);usedCron=cron6}catch(e2){logError"scheduleCheck(): failed to schedule refresh (${e2.message})"}}
if(usedCron)logInfo"Monitoring scheduled every ${interval} minutes at ${offset} past the hour.";atomicState.schedInterval=interval;atomicState.schedOffset=offset
usedCron=null;def wdCron7="0 ${wdOffset}/${interval} * ? * * *";def wdCron6="0 ${wdOffset}/${interval} * * * ?"
try{schedule(wdCron7,"watchdog");usedCron=wdCron7}catch(ex){try{schedule(wdCron6,"watchdog");usedCron=wdCron6}catch(e2){logError"scheduleCheck(): failed to schedule watchdog (${e2.message})"}}
if(usedCron)logInfo"Watchdog scheduled every ${interval} minutes at ${wdOffset} past the hour."
}else logDebug"scheduleCheck(): no change to interval/offset (still ${interval}/${offset})"
}
private void watchdog(){
def lastStr=device.currentValue("lastUpdate");if(!lastStr)return
def last
try{last=Date.parse("MM/dd/yyyy h:mm:ss a",lastStr).time}
catch(e){logDebug"watchdog(): unable to parse lastUpdate='${lastStr}'";return}
def interval=runTime as Integer;def delta=now()-last
logDebug"watchdog(): interval=${interval} delta=${(delta/1000).toInteger()} lastUpdate='${lastStr}'"
if(delta<(interval*2*60000))return
logDebug"No UPS update for ${(delta/60000).toInteger()} minutes"
emitEvent("connectStatus","Watchdog","No UPS update for ${(delta/60000).toInteger()} minutes",null,true)
return
}
/* ===============================
Command Helpers
=============================== */
private checkSessionTimeout(Map data){
def cmd=data?.cmd?:'Unknown';def start=getTransient("sessionStart")?:0L;def elapsed=now()-start;def s=device.currentValue("connectStatus")
if(s!="Disconnected"&&elapsed>10000){
logWarn"checkSessionTimeout(): ${cmd} still ${s} after ${elapsed}ms — forcing cleanup"
emitChangedEvent("lastCommandResult","Failed","${cmd} watchdog-triggered recovery")
resetTransientState("checkSessionTimeout");updateConnectState("Disconnected");closeConnection()
}else logDebug"checkSessionTimeout(): ${cmd} completed or cleaned normally after ${elapsed}ms"
}
private void sendUPSCommand(String cmdName, List cmds){
if(!state.upsControlEnabled&&cmdName!="Reconnoiter"){
logWarn"${cmdName} called but UPS control is disabled";atomicState.remove("pendingDeferredCmd");return
}
def cs=device.currentValue("connectStatus");def sessionOwner=getTransient("sessionStart")
if(sessionOwner&&cs!="Disconnected"){
logInfo"${cmdName} deferred 10s (Telnet busy with ${atomicState.lastCommand})"
def deferralKey="deferralCount_${cmdName}";def deferralCount=(getTransient(deferralKey)?:0)+1
setTransient(deferralKey,deferralCount)
if(deferralCount>=3){
logWarn"sendUPSCommand(): ${cmdName} deferred ${deferralCount} times; forcing initialization"
clearTransient(deferralKey);initialize();return
}
atomicState.deferredCommand=cmdName;setTransient("currentCommand",cmdName)
def retryTarget=(cmdName=="Reconnoiter")?"refresh":cmdName
logDebug"sendUPSCommand(): scheduling deferred ${retryTarget} retry in 10s (attempt ${deferralCount})";runIn(10,retryTarget);return
}
if(atomicState.deferredCommand){logWarn"sendUPSCommand(): clearing deferredCommand (was=${atomicState.deferredCommand})"}
atomicState.remove("deferredCommand");updateCommandState(cmdName);updateConnectState("Initializing")
emitChangedEvent("lastCommandResult","Pending","${cmdName} queued for execution");logInfo"Executing UPS command: ${cmdName}"
try{
setTransient("sessionStart",now())
logDebug"sendUPSCommand(): session start timestamp = ${getTransient('sessionStart')}"
telnetClose();updateConnectState("Connecting");initTelnetBuffer()
state.pendingCmds=["$Username","$Password"]+cmds+["whoami"]
logDebug"sendUPSCommand(): Opening transient Telnet connection to ${upsIP}:${upsPort}"
safeTelnetConnect([ip:upsIP,port:upsPort.toInteger()])
runIn(10,"checkSessionTimeout",[data:[cmd:cmdName]])
logDebug"sendUPSCommand(): queued ${state.pendingCmds.size()} Telnet lines for delayed send"
runInMillis(500,"delayedTelnetSend")
}catch(e){
logError"sendUPSCommand(${cmdName}): ${e.message}"
emitChangedEvent("lastCommandResult","Failure")
updateConnectState("Disconnected");closeConnection()
}
}
private delayedTelnetSend(){
if(state.pendingCmds){
logDebug "delayedTelnetSend(): sending ${state.pendingCmds.size()} queued commands"
telnetSend(state.pendingCmds, 500);state.remove("pendingCmds")
}
}
private safeTelnetConnect(Map m){
def ip=m.ip,port=m.port as int,retries=(m.retries?:3)as int,delayMs=(m.delayMs?:10000)as int;def attempt=(state.safeTelnetRetryCount?:1)
if(device.currentValue("connectStatus")in["Connecting","Connected","UPSCommand"]){
if(attempt<=retries){
logInfo"safeTelnetConnect(): Session active, retrying in ${delayMs/1000}s (attempt ${attempt}/${retries})";state.safeTelnetRetryCount=attempt+1;runInMillis(delayMs,"safeTelnetConnect",[data:m])}
else{logError"safeTelnetConnect(): Aborted after ${retries} attempts ? session still busy";state.remove("safeTelnetRetryCount")};return}
try{logDebug"safeTelnetConnect(): attempt ${attempt}/${retries} connecting to ${ip}:${port}";telnetClose();telnetConnect(ip,port,null,null);state.remove("safeTelnetRetryCount");logDebug"safeTelnetConnect(): connection established"}
catch(e){
def msg=e.message;def retryAllowed=(attempt300)logError msg else if(diff>60)logWarn msg
}catch(e){logDebug"checkUPSClock(): ${e.message}"}finally{clearTransient("upsBannerRefTime")}
}
private handleBatteryData(def pair){
pair=pair.collect{it?.replaceAll(",","")}
def(p0,p1,p2,p3,p4,p5)=(pair+[null,null,null,null,null,null])
switch("$p0 $p1"){
case"Battery Voltage:":emitChangedEvent("batteryVoltage",p2,"Battery Voltage = ${p2} ${p3}",p3);break
case"Battery State":if(p2=="Of"&&p3=="Charge:"){int pct=p4.toDouble().toInteger();emitChangedEvent("battery",pct,"UPS Battery Percentage = $pct ${p5}","%")};break
case"Runtime Remaining:":def s=pair.join(" ");def m=s=~/Runtime Remaining:\s*(?:(\d+)\s*(?:hr|hrs))?\s*(?:(\d+)\s*(?:min|mins))?/;int h=0,mn=0;if(m.find()){h=m[0][1]?.toInteger()?:0;mn=m[0][2]?.toInteger()?:0};def f=String.format("%02d:%02d",h,mn);emitChangedEvent("runTimeHours",h,"UPS Run Time Remaining = ${f}","h");emitChangedEvent("runTimeMinutes",mn,"UPS Run Time Remaining = ${f}","min");logInfo"UPS Run Time Remaining = ${f}"
try{def remMins=(h*60)+mn;def threshold=(settings.runTimeOnBattery?:2)*2;def prevLow=(device.currentValue("lowBattery")as Boolean)?:false;def isLow=remMins<=threshold
if(isLow!=prevLow){emitChangedEvent("lowBattery",isLow,"UPS low battery state changed to ${isLow}")
if(isLow){logWarn"Battery below ${threshold} minutes (${remMins} min remaining)"
if((settings.autoShutdownHub?:false)&&!state.hubShutdownIssued){if(!(upsStatus in["Online","Off"])){logWarn"Initiating Hubitat shutdown...";sendHubShutdown();state.hubShutdownIssued=true}}else if(state.hubShutdownIssued)logDebug"Hub shutdown already issued; skipping repeat trigger"
}else{logInfo"Battery run time recovered above ${threshold} minutes (${remMins} remaining)";state.remove("hubShutdownIssued")}
}
}catch(e){logWarn"handleBatteryData(): low-battery evaluation error (${e.message})"};break
case"Next Battery":if(p1=="Replacement"&&p2=="Date:"){def nd=p3?normalizeDate(p3):"Unknown";emitChangedEvent("nextBatteryReplacement",nd,"UPS Next Battery Replacement Date = ${nd}")};break
default:if((p0 in["Internal","Battery"])&&p1=="Temperature:"){emitChangedEvent("temperatureC",p2,"UPS Temperature = ${p2}°${p3}","°C");emitChangedEvent("temperatureF",p4,"UPS Temperature = ${p4}°${p5}","°F");if(tempUnits=="F")emitChangedEvent("temperature",p4,"UPS Temperature = ${p4}°${p5} / ${p2}°${p3}","°F")else emitChangedEvent("temperature",p2,"UPS Temperature = ${p2}°${p3} / ${p4}°${p5}","°C")};break
}
}
private handleElectricalMetrics(def pair){
pair=pair.collect{it?.replaceAll(",","")}
def(p0,p1,p2,p3,p4,p5)=(pair+[null,null,null,null,null,null])
switch(p0){
case"Output":
switch(p1){
case"Voltage:":emitChangedEvent("outputVoltage",p2,"Output Voltage = ${p2} ${p3}",p3);break
case"Frequency:":emitChangedEvent("outputFrequency",p2,"Output Frequency = ${p2} ${p3}","Hz");break
case"Current:":emitChangedEvent("outputCurrent",p2,"Output Current = ${p2} ${p3}",p3);def v=device.currentValue("outputVoltage");if(v){double w=v.toDouble()*p2.toDouble();emitChangedEvent("outputWatts",w.toInteger(),"Calculated Output Watts = ${w.toInteger()} W","W")};break
case"Energy:":emitChangedEvent("outputEnergy",p2,"Output Energy = ${p2} ${p3}",p3);break
case"Watts":if(p2=="Percent:")emitChangedEvent("outputWattsPercent",p3,"Output Watts = ${p3}${p4}","%");break
case"VA":if(p2=="Percent:")emitChangedEvent("outputVAPercent",p3,"Output VA = ${p3} ${p4}","%");break
};break
case"Input":
switch(p1){
case"Voltage:":emitChangedEvent("inputVoltage",p2,"Input Voltage = ${p2} ${p3}",p3);break
case"Frequency:":emitChangedEvent("inputFrequency",p2,"Input Frequency = ${p2} ${p3}","Hz");break
};break
}
}
private handleIdentificationAndSelfTest(def pair){
def(p0,p1,p2,p3,p4,p5)=(pair+[null,null,null,null,null,null])
switch(p0){
case"Serial":if(p1=="Number:"){logDebug "UPS Serial Number parsed: ${p2}";emitEvent("serialNumber",p2,"UPS Serial Number = $p2")};break
case"Manufacture":if(p1=="Date:"){def dt=normalizeDateTime(p2);logDebug "UPS Manufacture Date parsed: ${dt}";emitEvent("manufactureDate",dt,"UPS Manufacture Date = $dt")};break
case"Model:":def model=[p1,p2,p3,p4,p5].findAll{it}.join(" ").trim().replaceAll(/\s+/," ");emitEvent("model",model,"UPS Model = $model");break
case"Firmware":if(p1=="Revision:"){def fw=[p2,p3,p4].findAll{it}.join(" ");emitEvent("firmwareVersion",fw,"Firmware Version = $fw")};break
case"Self-Test":if(p1=="Date:"){def dt=normalizeDateTime(p2);emitEvent("lastSelfTestDate",dt,"UPS Last Self-Test Date = $dt")};if(p1=="Result:"){def r=[p2,p3,p4,p5].findAll{it}.join(" ");emitEvent("lastSelfTestResult",r,"UPS Last Self Test Result = $r")};break
}
}
private handleUPSCommands(def pair){
if(!pair) return;def code=pair[0]?.trim(),desc=translateUPSError(code),cmd=atomicState.lastCommand
def validCmds=["Alarm Test","Self Test","UPS On","UPS Off","Reboot","Sleep","Calibrate Run Time","setOutletGroup"]
if(!(cmd in validCmds))return
if(code in["E000:","E001:"]){emitChangedEvent("lastCommandResult","Success","Command '${cmd}' acknowledged by UPS (${desc})");logInfo"UPS Command '${cmd}' succeeded (${desc})";return}
def contextualDesc=desc
switch(cmd){
case"Calibrate Run Time":if(code in["E102:","E100:"])contextualDesc="Refused to start calibration – likely low battery or load conditions.";break
case"UPS Off":if(code=="E102:")contextualDesc="UPS refused shutdown – check outlet group configuration or NMC permissions.";break
case"UPS On":if(code=="E102:")contextualDesc="UPS power-on command refused – output already on or control locked.";break
case"Reboot":if(code=="E102:")contextualDesc="UPS reboot not accepted – possibly blocked by run time calibration or load conditions.";break
case"Sleep":if(code=="E102:")contextualDesc="UPS refused sleep mode – ensure supported model and conditions.";break
case"Alarm Test":if(code=="E102:")contextualDesc="Alarm test not accepted – may already be active or UPS in transition.";break
case"Self Test":if(code=="E102:")contextualDesc="Self test refused – battery charge insufficient or UPS busy.";break
}
emitChangedEvent("lastCommandResult","Failure","Command '${cmd}' failed (${code} ${contextualDesc})")
logWarn"UPS Command '${cmd}' failed (${code} ${contextualDesc})"
}
private handleAlarmCount(List lines){
lines.each{l->
def mCrit=l=~/CriticalAlarmCount:\s*(\d+)/;if(mCrit.find())emitChangedEvent("alarmCountCrit",mCrit.group(1).toInteger(),"Critical Alarm Count = ${mCrit.group(1)}")
def mWarn=l=~/WarningAlarmCount:\s*(\d+)/;if(mWarn.find())emitChangedEvent("alarmCountWarn",mWarn.group(1).toInteger(),"Warning Alarm Count = ${mWarn.group(1)}")
def mInfo=l=~/InformationalAlarmCount:\s*(\d+)/; if(mInfo.find())emitChangedEvent("alarmCountInfo",mInfo.group(1).toInteger(),"Informational Alarm Count = ${mInfo.group(1)}")
}
}
private void handleBannerData(String l){
def mName=(l =~ /^Name\s*:\s*([^\s]+).*/)
if(mName.find()){
def nameVal=mName.group(1).trim()
def curLbl=device.getLabel()
if(useUpsNameForLabel){
if(state.upsControlEnabled){
logDebug "handleBannerData(): Skipping label update – UPS Control Enabled"
} else if(curLbl!=nameVal){
device.setLabel(nameVal);logInfo "Device label updated from $curLbl to UPS name: $nameVal"
}
}
emitChangedEvent("deviceName",nameVal)
}
def mUp=(l =~ /Up\s*Time\s*:\s*(.+?)\s+Stat/)
if(mUp.find()){def v=mUp.group(1).trim();emitChangedEvent("upsUptime", v, "UPS Uptime = ${v}")}
def mDate=(l =~ /Date\s*:\s*(\d{2}\/\d{2}\/\d{4})/);if(mDate.find())setTransient("upsBannerDate",mDate.group(1).trim())
def mTime=(l =~ /Time\s*:\s*(\d{2}:\d{2}:\d{2})/)
if(mTime.find()&&getTransient("upsBannerDate")){
def upsRaw="${getTransient('upsBannerDate')} ${mTime.group(1).trim()}"
def upsDt=normalizeDateTime(upsRaw);emitChangedEvent("upsDateTime", upsDt,"UPS Date/Time = ${upsDt}")
def epoch=Date.parse("MM/dd/yyyy HH:mm:ss",upsRaw).time
setTransient("upsBannerEpoch",epoch);checkUPSClock(epoch)
clearTransient("upsBannerDate");clearTransient("upsBannerEpoch")
}
def mStat=(l =~ /Stat\s*:\s*(.+)$/);if(mStat.find()){
def statVal=mStat.group(1).trim()
setTransient("nmcStatusDesc",translateNmcStatus(statVal))
emitChangedEvent("nmcStatus",statVal,"${getTransient('nmcStatusDesc')}")
if(statVal.contains('-')||statVal.contains('!'))
logWarn"NMC is reporting an error state: ${getTransient('nmcStatusDesc')}"
clearTransient("nmcStatusDesc")
}
def mContact=(l =~ /Contact\s*:\s*(.*?)\s+Time\s*:/)
if(mContact.find()){
def contactVal=mContact.group(1).trim();emitChangedEvent("upsContact",contactVal,"UPS Contact = ${contactVal}")
}
def mLocation=(l =~ /Location\s*:\s*(.*?)\s+User\s*:/);if(mLocation.find()){
def locationVal=mLocation.group(1).trim()
emitChangedEvent("upsLocation",locationVal,"UPS Location = ${locationVal}")
}
}
private handleUPSSection(List lines){
lines.each{l->if(l.startsWith("Usage: ups")){state.upsSupportsOutlet=l.contains("-o");logInfo "UPS outlet group support: ${state.upsSupportsOutlet?'True':'False'}"}}
}
private void handleNMCData(List lines){
lines.each{l->
if(l=~/Hardware Factory/){setTransient("aboutSection","Hardware");return}
if(l=~/Application Module/){setTransient("aboutSection","Application");return}
if(l=~/APC OS\(AOS\)/){setTransient("aboutSection","OS");return}
if(l=~/APC Boot Monitor/){setTransient("aboutSection","BootMon");return}
def p=l.split(":",2);if(p.size()<2)return
def k=p[0].trim(),v=p[1].trim(),s=getTransient("aboutSection")
switch(s){
case"Hardware":
if(k=="Model Number")emitChangedEvent("nmcModel",v,"NMC Model = ${v}")
if(k=="Serial Number"){logDebug "NMC Serial Number parsed: ${v}";emitChangedEvent("nmcSerialNumber",v,"NMC Serial Number = ${v}")}
if(k=="Hardware Revision")emitChangedEvent("nmcHardwareRevision",v,"NMC Hardware Revision = ${v}")
if(k=="Manufacture Date"){def dt=normalizeDateTime(v);logDebug "NMC Manufacture Date parsed: ${dt}";emitChangedEvent("nmcManufactureDate",dt,"NMC Manufacture Date = ${dt}")}
if(k=="MAC Address"){def mac=v.replaceAll(/\s+/,":").toUpperCase();emitChangedEvent("nmcMACAddress",mac,"NMC MAC Address = ${mac}")}
if(k=="Management Uptime"){logDebug "NMC Uptime parsed: ${v}";emitChangedEvent("nmcUptime",v,"NMC Uptime = ${v}")};break
case"Application":
if(k=="Name")emitChangedEvent("nmcApplicationName",v,"NMC Application Name = ${v}")
if(k=="Version")emitChangedEvent("nmcApplicationVersion",v,"NMC Application Version = ${v}")
if(k=="Date")setTransient("nmcAppDate",v)
if(k=="Time"){def raw=(getTransient("nmcAppDate")?:"")+" "+v;def dt=normalizeDateTime(raw);emitChangedEvent("nmcApplicationDate",dt,"NMC Application Date = ${dt}");clearTransient("nmcAppDate")};break
case"OS":
if(k=="Name")emitChangedEvent("nmcOSName",v,"NMC OS Name = ${v}")
if(k=="Version")emitChangedEvent("nmcOSVersion",v,"NMC OS Version = ${v}")
if(k=="Date")setTransient("nmcOSDate",v)
if(k=="Time"){def raw=(getTransient("nmcOSDate")?:"")+" "+v;def dt=normalizeDateTime(raw);emitChangedEvent("nmcOSDate",dt,"NMC OS Date = ${dt}");clearTransient("nmcOSDate")};break
case"BootMon":
if(k=="Name")emitChangedEvent("nmcBootMonitor",v,"NMC Boot Monitor = ${v}")
if(k=="Version")emitChangedEvent("nmcBootMonitorVersion",v,"NMC Boot Monitor Version = ${v}")
if(k=="Date")setTransient("nmcBootMonDate",v)
if(k=="Time"){def raw=(getTransient("nmcBootMonDate")?:"")+" "+v;def dt=normalizeDateTime(raw);emitChangedEvent("nmcBootMonitorDate",dt,"NMC Boot Monitor Date = ${dt}");clearTransient("nmcBootMonDate")};break
}
}
clearTransient("aboutSection")
}
private handleBannerSection(List lines){lines.each{l->handleBannerData(l)}}
private handleUPSAboutSection(List lines){lines.each{l->handleIdentificationAndSelfTest(l.split(/\s+/))}}
private handleDetStatus(List lines){lines.each{l->def p=l.split(/\s+/);handleUPSStatus(p);handleLastTransfer(p);handleBatteryData(p);handleElectricalMetrics(p);handleIdentificationAndSelfTest(p);handleUPSCommands(p)};def cmd=(atomicState.lastCommand?:'').toLowerCase()}
private List extractSection(List