/**
* Advanced Lighting Circadian Rhythm
*/
definition(
name: "Advanced Lighting Circadian Rhythm",
namespace: "ShaneAllen",
author: "ShaneAllen",
description: "Commercial-grade Circadian Rhythm engine. Calculates natural solar color temperature based on local sunrise/sunset and outputs to a Hub Variable. Features live diagnostics, Daytime Storm Compensation (Lux tracking), and manual overrides. This is free-use code.",
category: "Convenience",
iconUrl: "",
iconX2Url: "",
iconX3Url: ""
)
preferences {
page(name: "mainPage")
}
def mainPage() {
dynamicPage(name: "mainPage", title: "Advanced Lighting Circadian Rhythm", install: true, uninstall: true) {
section("Live Circadian Dashboard") {
paragraph "
What it does: Provides a real-time view of the sun's position, outdoor lux, and the exact color temperature (Kelvin) and dimmer level (%) the engine is transmitting to your outputs.
"
def statusExplanation = getHumanReadableStatus()
paragraph "" +
"Engine Status: ${statusExplanation}
"
// Gather Core Metrics
def currentLux = luxSensor ? luxSensor.currentValue("illuminance") ?: "--" : "N/A"
def currentCT = state.calculatedCT ?: "--"
def currentLevel = state.calculatedLevel ?: "--"
def overrideModeStr = overrideMode ?: "Normal"
// Sun Position Math for Dashboard
def sunData = getSunriseAndSunset()
def riseTime = sunData.sunrise ? sunData.sunrise.format("h:mm a", location.timeZone) : "--"
def setTime = sunData.sunset ? sunData.sunset.format("h:mm a", location.timeZone) : "--"
def phase = "Nighttime"
if (state.stormModeActive) {
phase = "Daytime Storm (Cozy Mode Active)"
} else if (sunData.sunrise && sunData.sunset) {
def nowMs = now()
def riseMs = sunData.sunrise.time
def setMs = sunData.sunset.time
def solarNoonMs = riseMs + ((setMs - riseMs) / 2)
if (nowMs >= riseMs && nowMs < solarNoonMs) phase = "Morning (Ramping Up)"
else if (nowMs >= solarNoonMs && nowMs < setMs) phase = "Afternoon (Ramping Down)"
else if (nowMs >= setMs) phase = "Post-Sunset (Locked to Night Settings)"
else phase = "Pre-Sunrise (Locked to Night Settings)"
}
// Unified Dashboard HTML
def levelOutputDisplay = "Not Configured"
if (levelOutputType == "Virtual Dimmer Device" && outDimmer) levelOutputDisplay = outDimmer.displayName
else if (levelOutputType == "Hub Variable" && levelVariable) levelOutputDisplay = levelVariable
else if (levelOutputType == "Both") levelOutputDisplay = "${levelVariable ?: 'Missing Var'} & ${outDimmer ? outDimmer.displayName : 'Missing Device'}"
def dashHTML = """
| Real-Time Lighting Metrics |
| Calculated Color Temp | ${currentCT}K |
| Calculated Dimmer Level | ${currentLevel}% |
| Outdoor Illuminance (Lux) | ${currentLux} |
| Active Override Mode | ${overrideModeStr} |
| Solar Positioning & Weather |
| Current Solar Phase | ${phase} |
| Local Sunrise | ${riseTime} |
| Local Sunset | ${setTime} |
| System Connections |
| Target CT Variable | ${ctVariable ?: "Not Configured"} |
| Target Level Output | ${levelOutputDisplay} |
"""
paragraph dashHTML
}
section("App Control & Master Kill Switch") {
paragraph "What it does: The master toggle for the application. If disabled, the app stops updating the Hub Variables completely, allowing you to manually control your lights without interference.
"
input "appEnableSwitch", "capability.switch", title: "Master Enable/Disable Switch (Optional)", required: false, multiple: false
}
section("1. Manual Overrides") {
paragraph "What it does: Instantly locks the color temperature and dimmer to a specific value, bypassing the sun logic entirely. Useful for tasks requiring bright white light at night, or forcing cozy lighting during a dark storm.
"
input "overrideMode", "enum", title: "Operating Mode", options: ["Normal (Track Sun)", "Force Cool & Bright (6500K / Max Level)", "Force Warm & Dim (2500K / Min Level)"], required: true, defaultValue: "Normal (Track Sun)", submitOnChange: true
}
section("2. Environmental Sensors (Storm Compensation)") {
paragraph "What it does: Connect an outdoor Lux (Illuminance) sensor. If it gets unusually dark during the middle of the day (like a heavy rainstorm), the app will temporarily drop the color temperature and brightness to a cozy, warm setting until the sun comes back out.
"
input "luxSensor", "capability.illuminanceMeasurement", title: "Outdoor Lux Sensor (Optional)", required: false, submitOnChange: true
if (luxSensor) {
input "enableLuxOverride", "bool", title: "Enable Daytime Storm Compensation", defaultValue: false, submitOnChange: true
if (enableLuxOverride) {
input "luxThreshold", "number", title: "Lux Threshold (Drop values when Lux falls below this number)", required: false, defaultValue: 1000
input "luxTargetCT", "number", title: "Cozy Color Temp for Storms (Kelvin)", required: false, defaultValue: 3000
input "luxTargetLevel", "number", title: "Cozy Dimmer Level for Storms (%)", required: false, defaultValue: 50
}
}
}
section("3. Output Destinations") {
paragraph "What it does: The app continuously calculates the perfect Kelvin and Brightness percentage and injects them into your chosen destinations.
"
input "ctVariable", "text", title: "Exact Name of Color Temp Hub Variable (Required)", required: true
paragraph "Dimmer Level Destination:"
input "levelOutputType", "enum", title: "Where should the app send the calculated Dimmer Level?", options: ["Hub Variable", "Virtual Dimmer Device", "Both"], required: true, defaultValue: "Hub Variable", submitOnChange: true
if (levelOutputType == "Hub Variable" || levelOutputType == "Both") {
input "levelVariable", "text", title: "Exact Name of Dimmer Level Hub Variable", required: true
}
if (levelOutputType == "Virtual Dimmer Device" || levelOutputType == "Both") {
input "outDimmer", "capability.switchLevel", title: "Select Virtual Dimmer Device", required: true, multiple: false
}
}
section("4. Circadian Boundaries & Curves") {
paragraph "What it does: Defines the absolute floor and ceiling for your color temperature and brightness. Also allows you to invert the dimming logic so lights get dimmer as the evening approaches.
"
paragraph "Color Temperature Range:"
input "minCT", "number", title: "Minimum Warmth (Kelvin) - Used at Night", required: true, defaultValue: 2500
input "maxCT", "number", title: "Maximum Coolness (Kelvin) - Used at Solar Noon", required: true, defaultValue: 6500
paragraph "Dimmer Level Range:"
input "minLevel", "number", title: "Minimum Brightness (%)", required: true, defaultValue: 10
input "maxLevel", "number", title: "Maximum Brightness (%)", required: true, defaultValue: 100
input "dimCurveType", "enum", title: "Dimmer Tracking Logic", options: [
"Standard (Bright Midday, Dim Night)",
"Inverted (Dim Midday, Bright Night)"
], required: true, defaultValue: "Standard (Bright Midday, Dim Night)"
}
section("5. Update Frequency") {
paragraph "What it does: How often the app recalculates the sun's position and updates your outputs. 15 minutes provides a smooth, unnoticeable transition throughout the day.
"
input "updateInterval", "enum", title: "Calculation Interval", options: ["1":"Every 1 Minute", "5":"Every 5 Minutes", "15":"Every 15 Minutes", "30":"Every 30 Minutes"], required: false, defaultValue: "15"
input "txtEnable", "bool", title: "Enable Description Text Logging", defaultValue: true
}
}
}
// ==============================================================================
// INTERNAL LOGIC ENGINE
// ==============================================================================
def installed() { logInfo("Installed"); initialize() }
def updated() { logInfo("Updated"); unsubscribe(); unschedule(); initialize() }
def initialize() {
state.calculatedCT = null
state.calculatedLevel = null
state.stormModeActive = false
if (appEnableSwitch) subscribe(appEnableSwitch, "switch", enableSwitchHandler)
if (luxSensor) subscribe(luxSensor, "illuminance", luxHandler)
// Schedule the heartbeat sweep
def interval = updateInterval ?: "15"
if (interval == "1") runEvery1Minute(routineSweep)
else if (interval == "5") runEvery5Minutes(routineSweep)
else if (interval == "15") runEvery15Minutes(routineSweep)
else if (interval == "30") runEvery30Minutes(routineSweep)
logAction("Circadian Engine Initialized. Sun tracking active.")
evaluateSystem()
}
def enableSwitchHandler(evt) {
if (evt.value == "off") {
logAction("Circadian App Paused via Master Switch.")
state.stormModeActive = false
} else {
evaluateSystem()
}
}
def luxHandler(evt) {
if (txtEnable) log.info "${app.label}: Outdoor Lux updated to ${evt.value}"
// If Lux override is enabled, evaluate instantly when brightness changes rapidly
if (enableLuxOverride) {
evaluateSystem()
}
}
def routineSweep() {
evaluateSystem()
}
def getHumanReadableStatus() {
if (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") return "The application is suspended via the Master Switch."
if (overrideMode == "Force Cool & Bright (6500K / Max Level)") return "Manual Override: Locked to 6500K and Max Brightness."
if (overrideMode == "Force Warm & Dim (2500K / Min Level)") return "Manual Override: Locked to 2500K and Min Brightness."
if (state.stormModeActive) return "Tracking normally, but Storm Compensation has engaged to warm and dim the house due to low outdoor light."
return "Tracking normally. Calculating color and brightness curves based on solar position."
}
def evaluateSystem() {
if (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") return
if (!ctVariable) return // CT Var is the only absolute requirement
def targetCT = 2700 // Fallback default
def targetLevel = 100 // Fallback default
state.stormModeActive = false // Reset prior to logic check
// 1. Handle Manual Overrides
if (overrideMode == "Force Cool & Bright (6500K / Max Level)") {
targetCT = 6500
targetLevel = maxLevel ?: 100
if (txtEnable && (state.calculatedCT != targetCT || state.calculatedLevel != targetLevel)) {
logAction("Override Active: Forcing 6500K / Max Level.")
}
}
else if (overrideMode == "Force Warm & Dim (2500K / Min Level)") {
targetCT = 2500
targetLevel = minLevel ?: 10
if (txtEnable && (state.calculatedCT != targetCT || state.calculatedLevel != targetLevel)) {
logAction("Override Active: Forcing 2500K / Min Level.")
}
}
// 2. Handle Normal Sun Tracking & Lux Override
else {
targetCT = calculateNaturalCT()
targetLevel = calculateNaturalLevel()
// Storm Compensation Override Logic
if (luxSensor && enableLuxOverride) {
def currentLux = luxSensor.currentValue("illuminance")
def luxThresh = luxThreshold ?: 1000
if (currentLux != null && currentLux <= luxThresh) {
def sunData = getSunriseAndSunset()
def nowMs = now()
// Only engage Storm Compensation if it is actually daytime
if (sunData.sunrise && sunData.sunset && nowMs > sunData.sunrise.time && nowMs < sunData.sunset.time) {
def stormCT = luxTargetCT ?: 3000
def stormLevel = luxTargetLevel ?: 50
// Override if storm settings are cozier than current sun curve expectations
def engaged = false
if (stormCT < targetCT) {
targetCT = stormCT
engaged = true
}
if (dimCurveType != "Inverted (Dim Midday, Bright Night)" && stormLevel < targetLevel) {
targetLevel = stormLevel
engaged = true
}
if (engaged) state.stormModeActive = true
}
}
}
}
// 3. Push to Outputs
if (state.calculatedCT != targetCT) {
state.calculatedCT = targetCT
def success = setGlobalVar(ctVariable, targetCT)
if (success) {
logAction("BMS Command -> CT Variable '${ctVariable}' updated to ${targetCT}K" + (state.stormModeActive ? " (Storm Mode)" : ""))
} else {
logAction("ERROR: Hubitat rejected the update for '${ctVariable}'. Check exact spelling and ensure it is a Number variable.")
}
}
if (state.calculatedLevel != targetLevel) {
state.calculatedLevel = targetLevel
// Push to Hub Variable if selected
if (levelOutputType == "Hub Variable" || levelOutputType == "Both") {
if (levelVariable) {
def success = setGlobalVar(levelVariable, targetLevel)
if (success) {
logAction("BMS Command -> Level Variable '${levelVariable}' updated to ${targetLevel}%" + (state.stormModeActive ? " (Storm Mode)" : ""))
} else {
logAction("ERROR: Hubitat rejected the update for '${levelVariable}'. Check exact spelling and ensure it is a Number variable.")
}
}
}
// Push to Virtual Dimmer if selected
if (levelOutputType == "Virtual Dimmer Device" || levelOutputType == "Both") {
if (outDimmer && outDimmer.currentValue("level") != targetLevel) {
outDimmer.setLevel(targetLevel)
logAction("BMS Command -> Virtual Dimmer '${outDimmer.displayName}' updated to ${targetLevel}%" + (state.stormModeActive ? " (Storm Mode)" : ""))
}
}
}
}
def calculateNaturalCT() {
def sunData = getSunriseAndSunset()
def minTemp = minCT ?: 2500
def maxTemp = maxCT ?: 6500
if (!sunData.sunrise || !sunData.sunset) {
logInfo("Could not retrieve Hubitat solar data. Defaulting to max coolness.")
return maxTemp
}
def nowMs = now()
def riseMs = sunData.sunrise.time
def setMs = sunData.sunset.time
def solarNoonMs = riseMs + ((setMs - riseMs) / 2)
def calculatedTemp = minTemp
if (nowMs < riseMs) {
calculatedTemp = minTemp
}
else if (nowMs >= riseMs && nowMs <= solarNoonMs) {
def percentage = (nowMs - riseMs) / (solarNoonMs - riseMs)
calculatedTemp = minTemp + ((maxTemp - minTemp) * percentage)
}
else if (nowMs > solarNoonMs && nowMs <= setMs) {
def percentage = (nowMs - solarNoonMs) / (setMs - solarNoonMs)
calculatedTemp = maxTemp - ((maxTemp - minTemp) * percentage)
}
else if (nowMs > setMs) {
calculatedTemp = minTemp
}
return (Math.round(calculatedTemp / 50.0) * 50).toInteger()
}
def calculateNaturalLevel() {
def sunData = getSunriseAndSunset()
def rawMin = minLevel ?: 10
def rawMax = maxLevel ?: 100
// Determine bounds based on inverse selection
def isStandard = (dimCurveType == "Standard (Bright Midday, Dim Night)")
def nightLevel = isStandard ? rawMin : rawMax
def noonLevel = isStandard ? rawMax : rawMin
if (!sunData.sunrise || !sunData.sunset) {
return nightLevel
}
def nowMs = now()
def riseMs = sunData.sunrise.time
def setMs = sunData.sunset.time
def solarNoonMs = riseMs + ((setMs - riseMs) / 2)
def calculatedLvl = nightLevel
if (nowMs < riseMs) {
calculatedLvl = nightLevel
}
else if (nowMs >= riseMs && nowMs <= solarNoonMs) {
def percentage = (nowMs - riseMs) / (solarNoonMs - riseMs)
calculatedLvl = nightLevel + ((noonLevel - nightLevel) * percentage)
}
else if (nowMs > solarNoonMs && nowMs <= setMs) {
def percentage = (nowMs - solarNoonMs) / (setMs - solarNoonMs)
calculatedLvl = noonLevel - ((noonLevel - nightLevel) * percentage)
}
else if (nowMs > setMs) {
calculatedLvl = nightLevel
}
return Math.round(calculatedLvl).toInteger()
}
def logAction(msg) {
if(txtEnable) log.info "${app.label}: ${msg}"
}
def logInfo(msg) {
if(txtEnable) log.info "${app.label}: ${msg}"
}