/* Holiday Lighting Manager Copyright 2022-2024 Mike Bishop, All Rights Reserved */ import groovy.transform.Field import java.util.GregorianCalendar; import java.time.*; import java.time.format.DateTimeFormatter; import static java.time.temporal.TemporalAdjusters.*; import java.text.*; definition ( name: "Holiday Lighting", namespace: "evequefou", author: "Mike Bishop", description: "Themed light shows on RGB lights on holidays", importUrl: "https://raw.githubusercontent.com/MikeBishop/hubitat-holiday/main/holiday-lights.groovy", category: "Lighting", iconUrl: "", iconX2Url: "" ) preferences { page(name: "mainPage") page(name: "deviceSelection") page(name: "holidayDefinitions") page(name: "pageEditHoliday") page(name: "pageImport") page(name: "pageColorSelect") page(name: "illuminationConfig") } Map mainPage() { debug("Rendering mainPage"); dynamicPage(name: "mainPage", title: "Holiday Lighting", install: true, uninstall: true) { initialize(); section() { paragraph "This app manages holiday lighting on RGB lights. " + "It will turn on the lights during the time selected for the holiday. " + "It can also turn on the lights at the time selected for " + "non-holiday illumination. These time periods can overlap." } section() { input "thisName", "text", title: "Name this instance", submitOnChange: true if(thisName) app.updateLabel("$thisName") def descr = "Choose which RGB/RGB bulbs to use" def deviceIndices = state.deviceIndices; if(deviceIndices.size()) { descr = "${deviceIndices.size()} devices selected" } href( name: "deviceSelectionHref", page: "deviceSelection", title: "RGB Device Selection", description: descr ) href( name: "illuminationConfigHref", page: "illuminationConfig", title: "Non-Holiday Illumination", description: "When should lights be turned to white for normal illumination?" ) href( name: "holidaySelectionHref", page: "holidayDefinitions", title: "Holiday Displays", description: "What colors on what special days?" ) input "debugSpew", "bool", title: "Log debug messages?", submitOnChange: true, defaultValue: false; } debug("Finished with mainPage"); } } Map deviceSelection() { debug("Rendering deviceSelection"); dynamicPage(name: "deviceSelection", title: "Devices to Use") { section("Devices for Holiday Display") { deviceSelector(); } section("Advanced") { input "suspendSwitch", "capability.switch", title: "Switch to pause all instructions to lights" if( suspendSwitchOrientation == null ) { suspendSwitchOrientation = true; } input "suspendSwitchOrientation", "bool", title: "Pause when switch is " + maybeBold("on", suspendSwitchOrientation == true) + " or " + maybeBold("off", suspendSwitchOrientation == false), defaultValue: true def booleanVars = getGlobalVarsByType("boolean").collect{ it.key } if( booleanVars.any() ) { if( suspendVarOrientation == null ) { suspendVarOrientation = true; } input "suspendVar", "enum", options: booleanVars.sort(), title: "Variable to pause all instructions to lights when true" input "suspendVarOrientation", "bool", title: "Pause when variable is " + maybeBold("true", suspendVarOrientation == true) + " or " + maybeBold("false", suspendVarOrientation == false), defaultValue: true } } debug("Finished with deviceSelection"); } } Map holidayDefinitions() { debug("Rendering holidayDefinitions"); dynamicPage(name: "holidayDefinitions", title: "Configure Holiday Displays") { sortHolidays() if( !state.colorIndices ) { debug("Creating colorIndices") state.colorIndices = [:]; } section() { paragraph "During the time selected for holiday lights, the colors " + "selected for any current holiday will be shown. If multiple holidays " + "overlap, the shortest holiday will be shown." paragraph "Single-day holidays display the night before and night " + "of the selected date. Multi-day holdays start " + "and end as indicated." } section("Devices and Times") { selectStartStopTimes("holiday", "Display holiday lights"); input "switchesForHoliday", "capability.switch", multiple: true, title: "Other switches to turn on when holiday lights are active" input "frequency", "enum", title: "Update Frequency", options: FREQ_OPTIONS, required: true def booleanVars = getGlobalVarsByType("boolean").collect{ it.key } if( booleanVars.any() ) { input "holidayVar", "enum", options: booleanVars.sort(), title: "Variable to indicate active holiday" } } debug("Colors are ${state.colorIndices.inspect()}") state.holidayIndices?.each { int i -> def title = "${settings["holiday${i}Name"]}" section(hideable: true, hidden: true, title) { // List colors here! def colorDescription = "No colors yet. Add some!" def colorsForThisHoliday = state.colorIndices["${i}"]; if( colorsForThisHoliday?.size() && colorsForThisHoliday.every { settings["holiday${i}Color${it}"] != null } ) { colorDescription = colorsForThisHoliday.collect { def colorMap; try { colorMap = evaluate(settings["holiday${i}Color${it}"].toString()); } catch (Exception ex) { warn("Rehydration failed with ${ex}") } def colorInRGB = HSVtoRGB(colorMap) ?: "#000000"; "
 
" }.join() } href( name: "selectColors${i}", page: "pageColorSelect", title: "Edit ${settings["holiday${i}Name"]} colors", description: colorDescription, params: [holidayIndex: i], ) href( name: "editHoliday${i}", page: "pageEditHoliday", title: "Edit ${settings["holiday${i}Name"]} schedule", description: StringifyDate(i), params: [holidayIndex: i], ) input "testHoliday${i}", "button", title: "Test Holiday", submitOnChange: true, width: 4 paragraph "", width: 1 def delete = "" input "deleteHoliday${i}", "button", title: "${delete} Delete", submitOnChange: true, width: 4 } } section("Add Holidays") { href( name: "editHoliday${state.nextHolidayIndex}", page: "pageEditHoliday", title: "Add new holiday...", params: [holidayIndex: state.nextHolidayIndex] ) input "importSelected", "enum", title: "Import holiday lists", options: GetDefaultHolidays().keySet(), multiple: true, submitOnChange: true, width: 6 if( importSelected ) { href( name: "importHref", page: "pageImport", title: "Import selected lists...", width: 6 ) } } debug("Finished with holidayDefinitions"); } } Map pageImport() { debug("Rendering pageImport"); dynamicPage(name: "pageImport", title: "Holiday Import progress") { def alreadyImported = state.imported ?: [:]; importSelected.each { def list = it section("Importing ${list}...") { try { def holidaysToImport = GetDefaultHolidays()[list]; debug("Plan to import ${holidaysToImport.size()} from ${holidaysToImport}") holidaysToImport.each { debug("Entering holiday parser...") def i = state.nextHolidayIndex; def source = it; debug("Attempting ${source.name}"); def importSearch = alreadyImported.find{ it.value == source["id"] && state.holidayIndices.contains(Integer.parseInt("${it.key}")) }; if( importSearch ) { debug("${source.name} already exists at index ${importSearch.key}") paragraph "${source.name} already imported; skipping" } else { app.updateSetting("holiday${i}Name", source.name) app.updateSetting("holiday${i}Span", source.startDate != null) ["Start", "End"].each { def key = it.toLowerCase() + "Date" if( source[key] ) { app.updateSetting("holiday${i}${it}Type", source[key].type) if( source[key].type != SPECIAL ) { if( source[key].type == ORDINAL ) { debug("Ordinal, ${source[key].ordinal} ${source[key].weekday.toString()} of ${source[key].month.toString()}") app.updateSetting("holiday${i}${it}Ordinal", source[key].ordinal.toString()) app.updateSetting("holiday${i}${it}Weekday", source[key].weekday.toString()) } else { //Fixed debug("Fixed, ${source[key].month.toString()} ${source[key].day}") app.updateSetting("holiday${i}${it}Day", source[key].day) } app.updateSetting("holiday${i}${it}Month", source[key].month.toString()) } else { app.updateSetting("holiday${i}${it}Special", source[key].special) } app.updateSetting("holiday${i}${it}Offset", source[key].offset ?: 0) } } def colorSettings = source["settings"]; app.updateSetting("holiday${i}Alignment", colorSettings["type"] != STATIC || colorSettings["colors"]?.size() > 1); app.updateSetting("holiday${i}Rotation", colorSettings["type"]); if( colorSettings["type"] == RANDOM ) { app.updateSetting("holiday${i}" + STAGGER, true); } colorSettings["colors"].each { def idToImport = AddColorToHoliday(i); app.updateSetting("holiday${i}Color${idToImport}", it.toString()); } def indices = state.holidayIndices; indices.add(i); state.holidayIndices = indices; state.nextHolidayIndex += 1; alreadyImported[i] = source["id"]; paragraph "Imported ${source.name}" } } paragraph "Finished importing ${list}!" } catch( Exception ex) { error(ex); paragraph "Importing failed!" } } state.imported = alreadyImported; } app.clearSetting("importSelected") debug("Finished with pageImport"); } } def pageEditHoliday(params) { debug("Rendering pageEditHoliday"); Integer i if( params?.holidayIndex != null ) { i = params.holidayIndex state.editingHolidayIndex = i } else { i = state.editingHolidayIndex warn("Unexpected contents of params: ${params}"); } if( i == state.nextHolidayIndex && holidayIsValid(i) ) { state.nextHolidayIndex += 1; state.holidayIndices.add(i); sortHolidays(); } def formatter = DateTimeFormatter.ofPattern("MMMM"); def monthOptions = Month.values().collectEntries { [it, LocalDate.now().with(it).format(formatter)] } formatter = DateTimeFormatter.ofPattern("EEEE"); def dayOptions = DayOfWeek.values().collectEntries { [it, LocalDate.now().with(it).format(formatter)]} def name = settings["holiday${i}Name"] ?: "New Holiday" dynamicPage(name: "pageEditHoliday", title: "Edit ${name}") { section("Holiday definition") { input "holiday${i}Name", "text", title: "Name", required: true input "holiday${i}Span", "bool", defaultValue: false, title: "Date range? (If false, lights show the night before and night of the holiday.)", submitOnChange: true } def dates = ["End"] if( settings["holiday${i}Span"] ) { dates.add(0, "Start") } dates.each{ def date = it section("${settings["holiday${i}Span"] ? it : "Select"} date") { debug("date is ${date}") input "holiday${i}${date}Type", "enum", title: "Type of Schedule", multiple: false, options: [ (FIXED): "Fixed date", (ORDINAL): "Fixed weekday", (SPECIAL): "Special days" ], submitOnChange: true, required: true if( settings["holiday${i}${date}Type"] in [ORDINAL, FIXED] ) { if( settings["holiday${i}${date}Type"] == ORDINAL ) { input "holiday${i}${date}Ordinal", "enum", title: "Which week?", options: ORDINALS, required: true, submitOnChange: true input "holiday${i}${date}Weekday", "enum", title: "Which day?", options: dayOptions, width: 5, required: true, submitOnChange: true paragraph "\nof", width: 2 } input "holiday${i}${date}Month", "enum", title: "Month", options: monthOptions, submitOnChange: true, width: 5, required: true if( settings["holiday${i}${date}Type"] == FIXED && settings["holiday${i}${date}Month"] ) { def numDays = Month.valueOf(unarray(settings["holiday${i}${date}Month"])).length(true) input "holiday${i}${date}Day", "number", title: "Date", range:"1..${numDays}", width: 5, required: true, submitOnChange: true } } else if (settings["holiday${i}${date}Type"] == SPECIAL ) { input "holiday${i}${date}Special", "enum", options: SPECIALS, required: true, submitOnChange: true } input "holiday${i}${date}Offset", "number", title: "Offset (optional)", range:"-60..60" } } debug("Finished with pageEditHoliday"); } } def pageColorSelect(params) { debug("Rendering pageColorSelect"); int i; if( params?.holidayIndex != null ) { i = params.holidayIndex state.editingHolidayIndex = i } else { i = state.editingHolidayIndex warn("Unexpected contents of params: ${params}"); } def name = settings["holiday${i}Name"] ?: "New Holiday" dynamicPage(name: "pageColorSelect", title: "Colors for ${name}") { section() { paragraph "Select the colors and display options for ${name}." def floatFreq = Double.parseDouble(frequency); def freqString = "${(int) floatFreq} minutes"; if( floatFreq <= 1 ) { freqString = "${(int) (floatFreq * 60)} seconds"; } paragraph "Static means that the colors will be applied to the lights once " + "and will not change. Otherwise, a new set of colors will " + "be applied to the lights every ${freqString}. " + "Random shuffles the colors between lights each time, attempting to avoid repeats. " + "Sequential means that the colors will advance through the colors strictly in order." } section("Display Options") { displayOptions("holiday${i}"); paragraph PICKER_JS, width: 1 } debug("colorIndices is now ${state.colorIndices.inspect()}") def colorsForThisHoliday = state.colorIndices["${i}"]; if( !colorsForThisHoliday ) { colorsForThisHoliday = []; state.colorIndices["${i}"] = colorsForThisHoliday; } debug("colorsForThisHoliday ${colorsForThisHoliday.inspect()}") colorsForThisHoliday.eachWithIndex { color, index -> drawColorSection("holiday${i}", color, index); } section("") { input "testHoliday${i}", "button", title: "Test Holiday", submitOnChange: true input "addColorToHoliday${i}", "button", title: "Add Color", submitOnChange: true } debug("Finished with pageColorSelect"); } } Map illuminationConfig() { debug("Rendering illuminationConfig"); dynamicPage(name: "illuminationConfig", title: "Illumination Configuration") { section() { paragraph "During the times and modes set below, the lights will be on, " + "either all the time or only when sensors indicate activity. You " + "can choose how the lights should behave when there's activity and " + "when there's not." paragraph "If the selected time overlaps with holiday times, the " + "holiday settings will take precedence unless activity is detected." paragraph PICKER_JS, width:1; } section("Control Switch") { input "illuminationSwitch", "capability.switch", title: "Switch to control/reflect illumination state", submitOnChange: true } section("Illumination timing") { selectStartStopTimes("illumination", "Illumination"); } def anySensors = motionTriggers || contactTriggers || lockTriggers; def anyTriggers = anySensors || illuminationSwitch; section("Activity Sensors") { input "motionTriggers", "capability.motionSensor", title: "Motion sensors to trigger lights when active", multiple: true, submitOnChange: true input "contactTriggers", "capability.contactSensor", title: "Contact sensors to trigger lights when open", multiple: true, submitOnChange: true input "lockTriggers", "capability.lock", title: "Locks to trigger lights when unlocked", multiple: true, submitOnChange: true if( anySensors ) { input "duration", "number", title: "How many minutes to stay illuminated after sensor activity stops?" } } def untriggeredMode = settings["untriggeredIlluminationMode"]; if( anyTriggers ) { section("Lights When " + (anySensors ? "Activity Detected" : "Switched On")) { getIlluminationConfig("triggered", false); input "otherIlluminationSwitches", "capability.switch", title: "Other switches to turn on", multiple: true } } else if( untriggeredMode == null || untriggeredMode == OFF ) { app.updateSetting("untriggeredIlluminationMode", state.deviceIndices.collect{settings["device${it}"]}*. hasCapability("ColorTemperature").any{ a -> a } ? CT : RGB); } section("Lights " + (anyTriggers ? "When Idle" : "During Illumination Period")) { getIlluminationConfig("untriggered", anyTriggers); } } } private void getIlluminationConfig(String prefix, Boolean allowOff) { def devices = state.deviceIndices.collect{settings["device${it}"]}; def areLightsCT = devices*.hasCapability("ColorTemperature"); def anyCT = areLightsCT.any{ a -> a }; def anyNonCT = areLightsCT.any{ a -> !a }; def options = []; def mode; if( allowOff ) { options.add(OFF); } if( anyCT ) { options.add(CT); } options.add(RGB); if( options.size() == 1 ) { mode = options[0]; app.updateSetting("${prefix}IlluminationMode", options[0]); } else if (options.size() == 2 ) { def proxyKey = "${prefix}IlluminationModeProxy"; mode = settings[proxyKey] ? options[1] : options[0]; app.updateSetting("${prefix}IlluminationMode", mode); input proxyKey, "bool", defaultValue: false, submitOnChange: true, title: maybeBold(options[0], mode == options[0]) + " or " + maybeBold(options[1], mode == options[1]) } else { mode = settings["${prefix}IlluminationMode"]; input "${prefix}IlluminationMode", "enum", title: "Illumination Mode", options: options, submitOnChange: true, defaultValue: options[0], required: true } if( mode == CT ) { input "${prefix}ColorTemperature", "number", title: "Color temperature", width: 6, required: true, defaultValue: "2700" input "${prefix}Level", "number", title: "Brightness", width: 6, range: "1..100", required: true, defaultValue: "100" if( anyNonCT ) { paragraph "Note: Some lights do not support color temperature, so they will be turned off." } } else if( mode == RGB ) { drawPicker("${prefix}IlluminationColor"); } } private selectStartStopTimes(prefix, description) { input "${prefix}StartTime", "enum", title: "${description} from...", width: 6, options: TIME_OPTIONS, submitOnChange: true input "${prefix}StopTime", "enum", title: "${description} until...", width: 6, options: TIME_OPTIONS, submitOnChange: true ["Start", "Stop"].each { if( settings["${prefix}${it}Time"] == CUSTOM ) { input "${prefix}${it}TimeCustom", "time", title: "Specify ${it.toLowerCase()} time:", width: 6 } else if (settings["${prefix}${it}Time"]) { input "${prefix}${it}TimeOffset", "number", title: "${it} offset in minutes:", width: 6, range: "-240:240" } } input "${prefix}Modes", "enum", title: "${description} during the following modes, regardless of time...", width: 6, options: location.getModes()*.toString(), multiple: true } private holidayIsValid(int i) { return settings["holiday${i}Name"] && holidayDateIsValid("holiday${i}End") && (!settings["holiday${i}Span"] || holidayDateIsValid("holiday${i}Start")) } private holidayDateIsValid(String key) { return settings["${key}Type"] in [ORDINAL, FIXED, SPECIAL] && ( (settings["${key}Type"] == ORDINAL && settings["${key}Ordinal"] && settings["${key}Weekday"] ) || (settings["${key}Type"] == FIXED && settings["${key}Day"]) || (settings["${key}Type"] == SPECIAL && settings["${key}Special"] in SPECIALS.keySet()) ) && (settings["${key}Type"] == SPECIAL || settings["${key}Month"] != null) } private holidayDate(int i, String dateType, int year) { def name = settings["holiday${i}Name"]; // debug("Finding concrete ${dateType} date for ${i} (${name})") if( dateType == "Start" && !settings["holiday${i}Span"]) { // For non-Span holidays, the start date is the day before the end date return holidayDate(i, "End", year)?.minusDays(1); } def key = "holiday${i}${dateType}"; def type = settings["${key}Type"]; def month = settings["${key}Month"]; Integer date = settings["${key}Day"]; def result; switch(type) { case FIXED: // debug("Fixed ${year}, ${month}, ${date}") result = LocalDate.of(year, Month.valueOf(month), date); break; case ORDINAL: def ordinal = settings["${key}Ordinal"] def weekday = settings["${key}Weekday"] // debug("Ordinal ${year}, ${month}, ${ordinal}, ${weekday}") result = LocalDate.of(year, Month.valueOf(month), 15). with(dayOfWeekInMonth(Integer.parseInt(ordinal), DayOfWeek.valueOf(weekday))); break; case SPECIAL: def special = settings["${key}Special"]; switch(special) { case "easter": result = easterForYear(year); break; case "passover": result = passoverForYear(year); break; case "roshHashanah": result = roshHashanahForYear(year); break; default: warn("Unknown special ${special}"); } break; default: warn("Invalid date format ${type} in holiday ${name}!"); return null; } def offset = settings["${key}Offset"] ?: 0; return result.plusDays(offset); } private sortHolidays() { def thisYear = LocalDate.now().getYear() def originalList = state.holidayIndices debug("Sorting holidays: ${originalList.inspect()}....") def invalid = originalList.findAll{!holidayIsValid(it)}; invalid.each{ warn("Invalid holiday ${it}"); DeleteHoliday(it); } def sortedList = originalList.minus(invalid).collect{ [it, holidayDate(it, "Start", thisYear), holidayDate(it, "End", thisYear)] }.sort{ a,b -> a[2] <=> b[2] ?: a[1] <=> b[1] }.collect{it[0]}; state.holidayIndices = sortedList; debug("List became ${sortedList.inspect()}") } void appButtonHandler(btn) { debug("Button ${btn} pressed"); if( btn.startsWith("deleteHoliday") ) { // Extract index def parts = btn.minus("deleteHoliday").split("Color") def index = Integer.parseInt(parts[0]); if( parts.size() == 1 ) { DeleteHoliday(index); } else { DeleteColor(index, Integer.parseInt(parts[1])); } } else if (btn.startsWith("addColorToHoliday")) { def holidayIndex = Integer.parseInt(btn.minus("addColorToHoliday")); AddColorToHoliday(holidayIndex); } else if (btn.startsWith("testHoliday")) { def holidayIndex = Integer.parseInt(btn.minus("testHoliday")); testHoliday(holidayIndex); } } private AddColorToHoliday(int holidayIndex) { debug("Adding color to holiday ${holidayIndex}"); if( !state.nextColorIndices ) { state.nextColorIndices = [:]; } def nextColor = state.nextColorIndices["${holidayIndex}"] ?: 0; def indicesForHoliday = state.colorIndices["${holidayIndex}"]; if( indicesForHoliday ) { indicesForHoliday.add(nextColor); state.colorIndices["${holidayIndex}"] = indicesForHoliday; } else { state.colorIndices["${holidayIndex}"] = [nextColor]; } debug("colorIndices is now ${state.colorIndices.inspect()}") state.nextColorIndices["${holidayIndex}"] = nextColor + 1; return nextColor; } private DeleteHoliday(int index) { debug("Deleting holiday ${index}"); state.holidayIndices.removeElement(index); settings.keySet().findAll{ it.startsWith("holiday${index}") && !(it.minus("holiday${index}")[0] as char).isDigit() }.each { debug("Removing setting ${it}") app.removeSetting(it); } [state.colorIndices, state.imported].each{ it?.findAll{ !state.holidayIndices.contains(Integer.parseInt(it.key)) }.each{ k,v -> it.remove(k); } }; } private DeleteColor(int holidayIndex, int colorIndex) { debug("Deleting color ${colorIndex} from holiday ${holidayIndex}"); state.colorIndices["${holidayIndex}"].removeElement(colorIndex); app.removeSetting("holiday${holidayIndex}Color${colorIndex}") } private StringifyDate(int index) { def dates = ["End"]; if( !holidayIsValid(index) ) { return "Invalid date"; } if( settings["holiday${index}Span"] ) { dates.add(0, "Start"); } dates.collect { try { def result = "" if( settings["holiday${index}${it}Type"] != SPECIAL ) { formatter = DateTimeFormatter.ofPattern("MMMM"); def monthEnum = unarray(settings["holiday${index}${it}Month"]); def month = Month.valueOf(monthEnum); def monthString = LocalDate.now().with(month).format(formatter); def offset = settings["holiday${index}${it}Offset"] if( offset && Integer.parseInt(offset.toString()) != 0 ) { if( offset instanceof String ) { offset = Integer.parseInt(offset); } def absOffset = Math.abs(offset) result += "${absOffset} day${absOffset > 1 ? "s" : ""} ${offset > 0 ? "after" : "before"} " } if( settings["holiday${index}${it}Type"] == ORDINAL ) { def ordinal = unarray(settings["holiday${index}${it}Ordinal"]); result += "${ORDINALS[ordinal]} "; def formatter = DateTimeFormatter.ofPattern("EEEE"); def dayEnum = unarray(settings["holiday${index}${it}Weekday"]); def day = DayOfWeek.valueOf(dayEnum); result += LocalDate.now().with(day).format(formatter) + " of " result += monthString; return result; } else { //Fixed return "${monthString} ${settings["holiday${index}${it}Day"]}" } } else { return SPECIALS[settings["holiday${index}${it}Special"]] } } catch(Exception ex) { error(ex); return "Invalid date" } }.join(" through ") } private unarray(thing) { if( thing instanceof ArrayList ) { return thing[0] } else { return thing } } void updated() { initialize(); beginStateMachine(); } void installed() { initialize(); } void initialize() { if( state.deviceIndices instanceof Boolean ) { state.deviceIndices = [] } state.appType = HOLIDAY; state.nextHolidayIndex = state.nextHolidayIndex ?: 0; state.holidayIndices = state.holidayIndices ?: []; state.nextDeviceIndex = state.nextDeviceIndex ?: 0; state.deviceIndices = state.deviceIndices ?: []; debug("Initialize.... ${state.nextHolidayIndex.inspect()} and ${state.holidayIndices.inspect()}") updateSettings(); } void updateSettings() { // Upgrade to triggered/untriggered illumination settings ["colorTemperature", "level", "illuminationColor"].each { if( settings[it] ) { app.updateSetting("triggered" + it[0].toUpperCase() + it[1..-1], settings[it]); app.removeSetting(it); } } // Migrate from sunriseTime/sunsetTime to midnight schedule unsubscribe(location, "sunriseTime"); unsubscribe(location, "sunsetTime"); unsubscribe(location, "systemStart"); subscribe(location, "systemStart", "recoverSunriseSunset"); startFixedSchedules(); if( suspendSwitch != null && suspendSwitchOrientation == null ) { suspendSwitchOrientation = true; } } // #region Event Handlers void beginStateMachine(event = null) { debug("Begin state machine"); unsubscribe(); unschedule(); removeAllInUseGlobalVar(); state.test = false; state.currentHoliday = null; state.sequentialIndex = null; subscribe(suspendSwitch, "switch", "determineNextLightMode"); if( suspendVar != null ) { subscribe(location, "variable:${suspendVar}", "determineNextLightMode"); addInUseGlobalVar(suspendVar); } if( holidayVar != null ) { addInUseGlobalVar(holidayVar); } // Basic subscriptions -- subscribe to switch changes and schedule begin/end // of other periods. if( !illuminationSwitch || illuminationSwitch.currentValue("switch") == "off" ) { subscribe(illuminationSwitch, "switch.on", "beginIlluminationPeriod"); state.lockIllumination = false; } else { subscribe(illuminationSwitch, "switch.off", "turnOffIllumination"); // Leave lockIllumination however it already is. } if( duringIlluminationPeriod() ) { manageTriggerSubscriptions(true, false, "triggerIllumination"); state.illuminationMode = anyIlluminationTriggers(); } if( duringHolidayPeriod() ) { def possibleHoliday = getCurrentOrNextHoliday(); state.currentHoliday = isDuringHoliday(possibleHoliday) ? possibleHoliday : null; } // Create schedules for things that don't change startFixedSchedules(); // Listen for mode changes. if( illuminationModes || holidayModes ) { subscribe(location, "mode", "onModeChange"); } // Listen for hub reboots. subscribe(location, "systemStart", "recoverSunriseSunset"); // Figure out where we go from here. recoverSunriseSunset(); } private testHoliday(index) { state.currentHoliday = index; state.test = true; turnOffIllumination(); beginHolidayPeriod(); if( settings["holiday${currentHoliday}Display"] != STATIC ) { [15, 30, 45].each { runIn(it, "conditionalLightUpdate", [overwrite: false]); } } runIn(60, "beginStateMachine"); } private onModeChange(evt) { debug("Mode changed to ${location.getMode().toString()}"); if( duringHolidayPeriod() ) { beginHolidayPeriod(); } else { endHolidayPeriod(); } if( duringIlluminationPeriod() ) { beginIlluminationPeriod(); } else { endIlluminationPeriod(); } } void renameVariable(String oldName, String newName) { ["suspendVar", "holidayVar"].each { if( settings[it] == oldName ) { app.updateSetting(it, newName); removeInUseGlobalVar(oldName); addInUseGlobalVar(newName); } } } private beginHolidayPeriod() { debug("Begin holiday period"); state.currentHoliday = state.currentHoliday ?: getCurrentOrNextHoliday(); if( state.currentHoliday == null ) { debug("No holiday is active"); // This will call determineNextLightMode() for us. endHolidayPeriod(); } else { // This will start the lights if the state is set, provided no triggers // pre-empt them. determineNextLightMode(); } } private conditionalLightUpdate() { def currentHoliday = state.currentHoliday; if( currentHoliday != null ) { debug("Do light update"); doLightUpdate( state.deviceIndices.collect{ "device${it}" }, state.colorIndices["${currentHoliday}"], "holiday${currentHoliday}" ) } } private endHolidayPeriod() { debug("Not in holiday period"); state.currentHoliday = null; state.lastColors = null; unscheduleLightUpdate(); determineNextLightMode(); } private unscheduleLightUpdate() { unschedule("conditionalLightUpdate"); unschedule("runHandler"); unschedule("setColor"); } private beginIlluminationPeriod(event = null) { if( illuminationSwitch?.getDeviceNetworkId() != null && event?.device?.getDeviceNetworkId() == illuminationSwitch?.getDeviceNetworkId() ) { if( !state.illuminationMode ) { state.lockIllumination = true; } else { debug("Ignoring duplicate switch trigger"); return; } } debug("Begin illumination period" + (event ? " after ${event.device} sent ${event.value}" : "")); // Subscribe to the triggers manageTriggerSubscriptions(true, false, "triggerIllumination"); state.illuminationMode = anyIlluminationTriggers(); determineNextLightMode(); } private anyIlluminationTriggers() { if( state.lockIllumination ) { return true; } def motion = motionTriggers.any { it.currentValue("motion") == "active" }; def contact = contactTriggers.any { it.currentValue("contact") == "open" }; def lock = lockTriggers.any { it.currentValue("lock").startsWith("unlocked") }; debug "Motion: ${motion}, ${motionTriggers ? motionTriggers*.currentValue("motion") : "none"}"; debug "Contact: ${contact}, ${contactTriggers ? contactTriggers*.currentValue("contact") : "none"}"; debug "Lock: ${lock}, ${lockTriggers ? lockTriggers*.currentValue("lock") : "none"}"; return motion || contact || lock; } private endIlluminationPeriod() { if( !duringIlluminationPeriod() ) { debug("End illumination period"); turnOffIllumination(); } else { debug("Not ending illumination period; still active"); } } private triggerIllumination(event = null) { debug("Illumination triggered" + (event ? " after ${event.device} sent ${event.value}" : "")); if( appIsSuspended() ) { // Stop doing anything debug("Suspended; ignoring triggers until released."); return; } state.illuminationMode = true; state.lightsActive = true; // Turn on the illumination switch, but stop listening to on unsubscribe(illuminationSwitch, "switch.on"); illuminationSwitch?.on(); subscribe(illuminationSwitch, "switch.off", "turnOffIllumination"); applyIlluminationSettings("triggered"); otherIlluminationSwitches*.on(); manageTriggerSubscriptions(false, true, "checkIlluminationOff"); unschedule("turnOffIllumination"); unscheduleLightUpdate(); } private Boolean appIsSuspended() { return suspendSwitch?.currentValue("switch") == (suspendSwitchOrientation ? "on" : "off") || ( suspendVar && getGlobalVar(suspendVar)?.value == suspendVarOrientation ); } private determineNextLightMode(event = null) { updateSettings(); def isHoliday = state.currentHoliday != null && duringHolidayPeriod(); def isIllumination = duringIlluminationPeriod(); def isTriggered = state.illuminationMode ?: false; if( appIsSuspended() ) { // Stop doing anything debug("Suspended; ignoring triggers until released."); unscheduleLightUpdate(); return; } debug("Determine next light mode: holiday=${isHoliday}, illumination=${isIllumination}, triggered=${isTriggered}"); if( isIllumination && isTriggered ) { triggerIllumination(); checkIlluminationOff(); } else { if( illuminationSwitch?.currentValue("switch") == "on" ) { debug("Illumination switch is on; turning off"); illuminationSwitch.off(); } switchesOff(otherIlluminationSwitches); if ( isHoliday ) { if( state.test || isDuringHoliday(state.currentHoliday) ) { debug("Holiday is active"); setGlobalVar(holidayVar, true); // We're going to start the display; unless it's static, // schedule the updates. def handlerName = "conditionalLightUpdate"; scheduleHandler(handlerName, frequency, settings["holiday${currentHoliday}Display"] != STATIC && state.colorIndices["${state.currentHoliday}"]?.size() > 1 && !state.test ); switchesForHoliday*.on(); state.lightsActive = true; } else { debug("Holiday is not active"); endHolidayPeriod(); } } else if ( isIllumination ) { switchesOff(switchesForHoliday); applyIlluminationSettings("untriggered"); } else { lightsOff(); } } } private applyIlluminationSettings(String prefix) { def mode = settings["${prefix}IlluminationMode"]; def devices = state.deviceIndices.collect{ settings["device${it}"] }; def ctDevices = devices?.findAll { it.hasCapability("ColorTemperature")} ?: []; debug("CT-capable devices: ${ctDevices.inspect()}"); def rgbOnlyDevices = devices?.minus(ctDevices) ?: []; debug("RGB-only devices: ${rgbOnlyDevices.inspect()}"); setGlobalVar(holidayVar, false); if( mode == null ) { if( prefix == "triggered" ) { mode = ( ( ctDevices.size() >= devices.size() / 2) && settings["triggeredColorTemperature"] != null) ? CT : RGB; } else { mode = OFF; } } debug("Illumination mode for ${prefix}: ${mode}"); switch( mode ) { case CT: def colorTemperature = settings["${prefix}ColorTemperature"]; def level = settings["${prefix}Level"]; if( colorTemperature == null ) { warn("No color temperature set for ${prefix} illumination; defaulting to 2700"); colorTemperature = 2700; } if( level == null ) { warn("No level set for illumination; defaulting to 100"); level = 100; } debug("Setting color temperature to ${colorTemperature}K and level to ${level}%"); ctDevices*.setColorTemperature(colorTemperature, level); switchesOff(rgbOnlyDevices); state.lightsActive = true; break; case RGB: def colorMap = null; def illuminationColor = settings["${prefix}IlluminationColor"]; if( illuminationColor != null ) { try { colorMap = evaluate(illuminationColor); } catch(Exception ex) { error(ex); } } if( colorMap == null ) { warn("No color set for illumination; defaulting to white"); colorMap = COLORS["White"]; } debug("Setting color to ${colorMap.inspect()}"); devices*.setColor(colorMap); state.lightsActive = true; break; case OFF: lightsOff(); break; default: error("Unknown illumination mode: ${mode}"); break; } } private checkIlluminationOff(event = null) { debug("Checking if illumination is still triggered" + (event ? " after ${event.device} sent ${event.value}" : "")); if( !anyIlluminationTriggers() ) { debug("No sensor activity detected, switching to untriggered in ${duration} minutes"); manageTriggerSubscriptions(false, true); runIn((duration ?: 0) * 60, "turnOffIllumination"); } } private turnOffIllumination(event = null) { if( illuminationSwitch && event?.device?.getDeviceNetworkId() == illuminationSwitch.getDeviceNetworkId() ) { if( state.illuminationMode ) { state.lockIllumination = false; } else { debug("Ignoring duplicate switch trigger"); return; } } debug("Illumination not triggered" + (event ? " after ${event.device} sent ${event.value}" : "")); state.illuminationMode = false; unschedule("turnOffIllumination"); unsubscribe(illuminationSwitch, "switch.off"); subscribe(illuminationSwitch, "switch.on", "beginIlluminationPeriod"); manageTriggerSubscriptions(!duringIlluminationPeriod(), true); determineNextLightMode(); } private dateIsBetweenInclusive(date, start, end) { return start && end && (date?.isEqual(start) || date?.isAfter(start)) && (date?.isBefore(end) || date?.isEqual(end)); } private getHolidayDates(index) { def today = LocalDate.now() def thisYear = today.getYear(); def nextYear = thisYear + 1; def lastYear = thisYear - 1; def startDate = holidayDate(index, "Start", thisYear); def endDate = holidayDate(index, "End", thisYear); if( !startDate || !endDate ) { // If calculations failed, don't try to do any fixups. return []; } // If the end date is before the start date, it crosses the year boundary. if( endDate.isBefore(startDate) ) { // Either it started last year and ends this year... if( today.isBefore(endDate) ) { startDate = holidayDate(index, "Start", lastYear); } // ...or it starts this year and ends next year. else { endDate = holidayDate(index, "End", nextYear); } } // If the end date has passed, we need to move to the next occurrence. if( endDate.isBefore(today) ) { startDate = holidayDate(index, "Start", startDate.getYear() + 1); endDate = holidayDate(index, "End", endDate.getYear() + 1); } // If the end time is before the start time, the display period crosses // into the following day. if( getLocalTime("holidayStop")?.isBefore(getLocalTime("holidayStart")) ) { endDate = endDate.plusDays(1); } debug("Holiday ${index} starts ${startDate} and ends ${endDate}"); return [startDate, endDate]; } private getCurrentOrNextHoliday() { def thisYear = LocalDate.now().getYear(); def nextYear = thisYear + 1; def today = LocalDate.now(); def futureHolidays = state.holidayIndices.collect{ def dates = getHolidayDates(it); [it, dates[0], dates[1]] }; debug("Future holidays: ${futureHolidays}"); def currentHolidays = futureHolidays.findAll{ def startDate = it[1]; def endDate = it[2]; dateIsBetweenInclusive(today, startDate, endDate); }; debug("Current holidays: ${currentHolidays}"); if( currentHolidays.size() > 1 ) { def result = currentHolidays.collect{ [it[0], Duration.between(it[1].atStartOfDay(), it[2].atStartOfDay())] }.sort{ a,b -> a[1] <=> b[1] }.first(); debug("Selected holiday: ${result}"); return result[0]; } else if ( currentHolidays.size() == 1 ) { debug("Selected holiday: ${currentHolidays[0]}"); return currentHolidays.first()[0]; } else if ( futureHolidays.size() ) { def result = futureHolidays. sort{ a,b -> a[1] <=> b[1] ?: a[2] <=> b[2] }.first(); debug("Next holiday: ${result}"); return result[0]; } else { debug("No holidays"); return null; } } private lightsOff() { if( state.lightsActive ) { debug("Turning off lights"); def devices = state.deviceIndices.collect{ settings["device${it}"] }; switchesOff(devices); switchesOff(otherIlluminationSwitches); switchesOff(switchesForHoliday); state.lightsActive = false; } setGlobalVar(holidayVar, false); } private switchesOff(switches) { switches.findAll{ it.currentValue("switch") == "on" }*.off(); } private manageTriggerSubscriptions(active, inactive, handler = null) { def triggerTypes = [["motion", "active", "inactive"], ["contact", "open", "closed"], ["lock", "unlocked", "locked"]]; triggerTypes.each{ def type = it[0]; def activeEvent = "${type}.${it[1]}"; def inactiveEvent = "${type}.${it[2]}"; if( handler ) { if( active ) this.subscribe(settings["${type}Triggers"], activeEvent, handler); if( inactive ) this.subscribe(settings["${type}Triggers"], inactiveEvent, handler); } else { if( active ) this.unsubscribe(settings["${type}Triggers"], activeEvent); if( inactive ) this.unsubscribe(settings["${type}Triggers"], inactiveEvent); } } } private scheduleSunriseAndSunset(event = null) { def now = new Date(); if( event ) { // Patch up legacy install updateSettings(); } // Schedule for today's events def sunrise = location.sunrise; def sunset = location.sunset; debug("Got sunrise: ${sunrise} and sunset: ${sunset} from location data"); // Sunrise/sunset just changed, so schedule the upcoming events... PREFIX_AND_HANDLERS.each { def prefix = it[0]; def handler = it[1]; def targetTime = settings["${prefix}Time"]; if ( [SUNRISE, SUNSET].contains(targetTime) ) { def offset = settings["${prefix}TimeOffset"] ?: 0; def time = targetTime == SUNRISE ? sunrise : sunset; // Apply offset def scheduleFor = Date.from(time.toInstant().plus(Duration.ofMinutes(offset))); if( scheduleFor.after(now) ) { debug("Scheduling ${prefix} for ${scheduleFor} (${targetTime} with ${offset} minutes offset)"); runOnce(scheduleFor, handler); return true; } else { debug("Not scheduling ${prefix} for ${scheduleFor}, which is in the past"); return false; } } } } // This runs at midnight and at the end of beginStateMachine() private void recoverSunriseSunset(event = null) { scheduleSunriseAndSunset(); determineNextLightMode(); } @Field static final List PREFIX_AND_HANDLERS = [ ["illuminationStart", "beginIlluminationPeriod"], ["illuminationStop", "endIlluminationPeriod"], ["holidayStart", "beginHolidayPeriod"], ["holidayStop", "endHolidayPeriod"] ]; private startFixedSchedules() { schedule("15 0 0 * * ?", "recoverSunriseSunset"); PREFIX_AND_HANDLERS.each { def prefix = it[0]; def handler = it[1]; if( settings["${prefix}Time"] == CUSTOM ) { def time = getAsTimeString(prefix); debug("Scheduling ${handler} for ${time} (custom)"); schedule(time, handler); } } } // #endregion // #region Time Helper Functions private getMaxTimePeriod(prefix, startStop) { def modesActive = (settings["${prefix}Modes"]?.size() ?: 0) > 0; def time = getLocalTime("${prefix}${startStop}"); if( time && !modesActive ) { return time; } else { return startStop == "Start" ? LocalTime.MIDNIGHT : LocalTime.MAX; } } private isDuringHoliday(currentHoliday) { def dates = getHolidayDates(currentHoliday); def startTime = LocalDateTime.of(dates[0], getMaxTimePeriod("holiday", "Start") ); def endTime = LocalDateTime.of(dates[1], getMaxTimePeriod("holiday", "Stop") ); def now = LocalDateTime.now(); return now.isAfter(startTime) && now.isBefore(endTime); } private getAsTimeString(prefix) { def legacyDate = timeToday(settings["${prefix}TimeCustom"]); if( legacyDate ) { return "0 " + new SimpleDateFormat("m H").format(legacyDate) + " * * ?"; } return null; } private Boolean duringHolidayPeriod() { return duringPeriod("holiday"); } private Boolean duringIlluminationPeriod() { return duringPeriod("illumination") || state.lockIllumination; } private Boolean duringPeriod(prefix) { def beginTime = getLocalTimeToday("${prefix}Start"); def endTime = getLocalTimeToday("${prefix}Stop"); def activeModes = settings["${prefix}Modes"]; if( activeModes?.contains(location.getMode().toString()) ) { return true; } if( !beginTime || !endTime ) { if( !activeModes ) { debug("No ${prefix} time set; ${beginTime} - ${endTime}"); } return false; } def reverseResults = endTime < beginTime; if( reverseResults ) (beginTime, endTime) = [endTime, beginTime]; def now = LocalDateTime.now(); def result = now.isAfter(beginTime) && now.isBefore(endTime); return reverseResults ? !result : result; } private LocalDateTime getLocalTimeToday(prefix) { def localTime = getLocalTime(prefix); if( localTime ) { return LocalDateTime.of(LocalDate.now(), localTime); } else { return null; } } private LocalDateTime getNextLocalTime(prefix) { def today = getLocalTimeToday(prefix); if( today && LocalDateTime.now().isAfter(today) ) { return today.plusDays(1); } else { return today; } } private LocalTime getLocalTime(prefix) { def offset = settings["${prefix}TimeOffset"] ?: 0; def result; switch(settings["${prefix}Time"]) { case SUNRISE: result = location.sunrise; break; case SUNSET: result = location.sunset; break; case CUSTOM: result = timeToday(settings["${prefix}TimeCustom"]); break; default: return null; } if( result ) { return LocalTime.parse(new SimpleDateFormat("HH:mm:ss").format(result)).plusMinutes(offset); } else { return null; } } // #endregion // #region Constants // UI Elements @Field static final Map ORDINALS = [ "1": "First", "2": "Second", "3": "Third", "4": "Fourth", "5": "Fifth", "-1": "Last" ] @Field static final Map TIME_OPTIONS = [ sunrise: "Sunrise", sunset: "Sunset", custom: "A specific time..." ]; @Field static final Map SPECIALS = [ "easter": "Easter", "passover": "Passover", "roshHashanah": "Rosh Hashanah", // Others to be added later ] @Field static final String ORDINAL = "ordinal"; @Field static final String FIXED = "fixed"; @Field static final String SPECIAL = "special"; @Field static final String SUNRISE = "sunrise"; @Field static final String SUNSET = "sunset"; @Field static final String CUSTOM = "custom"; @Field static final String OFF = "Off"; @Field static final String CT = "Color Temperature"; @Field static final String RGB = "RGB Color"; // Can't be constants because they reference other fields, but effectively constants. private Map GetHolidayByID(int id) { final Map RedWhiteAndBlue = [type: RANDOM, colors:[COLORS["Red"], COLORS["White"], COLORS["Blue"]]]; final List MasterHolidayList = [ // 0 [name: "Presidents Day", endDate: [type: ORDINAL, month: Month.FEBRUARY, weekday: DayOfWeek.MONDAY, ordinal: 3], settings: RedWhiteAndBlue], // 1 [name: "St. Patrick's Day", endDate: [type: FIXED, month: Month.MARCH, day: 17], settings: [ type: STATIC, colors: [COLORS["Green"]]]], // 2 [name: "Memorial Day", endDate: [type: ORDINAL, month: Month.MAY, weekday: DayOfWeek.MONDAY, ordinal: -1], settings: RedWhiteAndBlue], // 3 [name: "Pride Month", startDate: [type: FIXED, month: Month.JUNE, day: 1], endDate: [type: FIXED, month: Month.JUNE, day: 30], settings: [ type: SEQUENTIAL, colors: [COLORS["Red"], COLORS["Orange"], COLORS["Yellow"], COLORS["Green"], COLORS["Blue"], COLORS["Purple"]] ] ], // 4 [name: "Juneteenth", endDate: [type: FIXED, month: Month.JUNE, day: 19], settings: RedWhiteAndBlue], // 5 [name: "Independence Day", endDate: [type: FIXED, month: Month.JULY, day: 4], settings: RedWhiteAndBlue], // 6 [name: "Labor Day", endDate: [type: ORDINAL, month: Month.SEPTEMBER, weekday: DayOfWeek.MONDAY, ordinal: 1], settings: RedWhiteAndBlue], // 7 [name: "Veterans Day", endDate: [type: FIXED, month: Month.OCTOBER, day: 11], settings: RedWhiteAndBlue], // 8 [name: "Halloween", endDate: [type: FIXED, month: Month.OCTOBER, day: 31], settings: [ type: STATIC, colors: [COLORS["Orange"], COLORS["Indigo"]]]], // 9 [name: "Thanksgiving Day", endDate: [type: ORDINAL, month: Month.NOVEMBER, weekday: DayOfWeek.THURSDAY, ordinal: 4], settings: [ type: STATIC, colors: [COLORS["Orange"], COLORS["White"]]]], // 10 [name: "Christmas", startDate: [type: ORDINAL, month: Month.NOVEMBER, weekday: DayOfWeek.THURSDAY, ordinal: 4, offset: 1], endDate: [type: FIXED, month: Month.DECEMBER, day: 26], settings: [ type: RANDOM, colors: [COLORS["Red"], COLORS["Green"]]]], // 11 [name: "Valentine's Day", endDate: [type: FIXED, month: Month.FEBRUARY, day: 14], settings: [ type: RANDOM, colors: [COLORS["Red"], COLORS["Pink"], COLORS["Raspberry"]]]], // 12 [name: "Easter", endDate: [type: SPECIAL, special: "easter"], settings: [ type: STATIC, colors: [COLORS["White"], COLORS["Amber"]]]], // 13 [name: "Holy Week", startDate: [type: SPECIAL, special: "easter", offset: -7], endDate: [type: SPECIAL, special: "easter", offset: -1], settings: [ type: STATIC, colors: [COLORS["Purple"]]]], // 14 [name: "Pentecost", endDate: [type: SPECIAL, special: "easter", offset: 50], settings: [ type: STATIC, colors: [COLORS["Red"]]]], // 15 [name: "Epiphany", endDate: [type: FIXED, month: Month.JANUARY, day: 6], settings: [ type: STATIC, colors: [COLORS["Green"]]]], // 16 [name: "Passover", startDate: [type: SPECIAL, special: "passover", offset: -1], endDate: [type: SPECIAL, special: "passover", offset: 7], settings: [ type: STATIC, colors: [COLORS["Blue"], COLORS["Purple"]]]], // 17 [name: "Rosh Hashanah", endDate: [type: SPECIAL, special: "roshHashanah"], settings: [ type: STATIC, colors: [COLORS["Red"], COLORS["White"]]]], ]; return MasterHolidayList[id]; } private Map GetDefaultHolidays() { final Map indices = [ "United States": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 ], "Christian": [ 10, 12, 13, 14, 15 ], "Jewish": [ 16, 17 ] ]; return indices.collectEntries{ [it.key, it.value.collect{ def holiday = GetHolidayByID(it); holiday["id"] = it; holiday }] } } // #endregion // #region Utility Methods private HSVtoRGB(Map hsv) { float r, g, b, i, f, p, q, t; float s = ( hsv?.saturation ?: 0 ) / 100; float v = ( hsv?.level ?: 0 ) / 100; float h = ( hsv?.hue ?: 0 ) / 100; i = Math.floor(h * 6); f = h * 6 - i; p = v * (1 - s); q = v * (1 - f * s); t = v * (1 - (1 - f) * s); switch (i % 6) { case 0: r = v; g = t; b = p; break; case 1: r = q; g = v; b = p; break; case 2: r = p; g = v; b = t; break; case 3: r = p; g = q; b = v; break; case 4: r = t; g = p; b = v; break; case 5: r = v; g = p; b = q; break; } return "#" + [ r, g, b ].collect{ def num = (int) Math.round(it * 255); String.format("%02x", num) }.join(); } private static easterForYear(int Y) { float A, B, C, P, Q, M, N, D, E; // All calculations done // on the basis of // Gauss Easter Algorithm // // Taken from https://www.geeksforgeeks.org/how-to-calculate-the-easter-date-for-a-given-year-using-gauss-algorithm/ A = Y % 19; B = Y % 4; C = Y % 7; P = (float)Math.floor(Y / 100); Q = (float)Math.floor( (13 + 8 * P) / 25); M = (15 - Q + P - P / 4) % 30; N = (4 + P - P / 4) % 7; D = (19 * A + M) % 30; E = (2 * B + 4 * C + 6 * D + N) % 7; int days = (int)(22 + D + E); // A corner case, // when D is 29 if ((D == 29) && (E == 6)) { return LocalDate.of(Y, 4, 19); } // Another corner case, // when D is 28 else if ((D == 28) && (E == 6)) { return LocalDate.of(Y, 4, 18); } else { // If days > 31, move to April // April = 4th Month if (days > 31) { return LocalDate.of(Y, 4, days-31); } // Otherwise, stay on March // March = 3rd Month else { return LocalDate.of(Y, 3, days); } } } private static LocalDate passoverForYear(int Y) { // Taken from https://webspace.science.uu.nl/~gent0113/easter/easter_text2a.htm int MH, DH; float A,B,P,S,Q,R,C,DMH; A = (12*Y + 12) % 19; B = Y % 4; S = (5*(1979335 - 313*Y)+(765433 * A))/492480 + B/4; Q = (float) Math.floor(S); R = S - Q; C = (Q + 3*Y + 5*B + 1) % 7; float diff_jg = (float) Math.floor(Y/100) - (float) Math.floor(Y/400)-2; DMH = Q + diff_jg + 92; P = 0; if((C == 2) || (C == 4) || (C == 6)) P=1; // because of Adu if((C == 1) && (A > 6) && (R > 1366/2160)) P=2; // because of Gatarad if((C == 0) && (A > 11) && (R > 23268/25920)) P=1; // because of Batu Thakpad DMH += P; MH = (int) Math.floor((DMH - 62)/30.6); DH = (int) Math.floor(DMH - 62 - 30.6*MH) + 1; MH += 2; return LocalDate.of(Y, MH, 1).plusDays(DH - 1); } private static LocalDate roshHashanahForYear(int year) { // Taken from https://quasar.as.utexas.edu/BillInfo/ReligiousCalendars.html int Y = year - 1900; int G = year % 19 + 1; int S = (11*G - 6) % 30; double nPlusFrac = 6.057778996 + 1.554241797*((12*G) % 19) + 0.25*(Y%4) - 0.003177794*Y; int N = (int) Math.floor(nPlusFrac); double fraction = nPlusFrac - N; LocalDate tentative = LocalDate.of(year, Month.SEPTEMBER, 1).plusDays(N - 1); switch(tentative.getDayOfWeek()) { case DayOfWeek.SUNDAY: case DayOfWeek.WEDNESDAY: case DayOfWeek.FRIDAY: return tentative.plusDays(1); case DayOfWeek.MONDAY: return tentative.plusDays((fraction > 0.898 && (12 * G) % 19 > 11) ? 1 : 0); case DayOfWeek.TUESDAY: return tentative.plusDays((fraction > 0.633 && (12 * G) % 19 > 6) ? 2 : 0); default: return tentative; } } // #endregion #include evequefou.color-tools