Tutorial/Example of Lights Control Using Lambdas ported to NGRE/python

Tags: #<Tag:0x00007f5c956a7e00> #<Tag:0x00007f5c956a7c70> #<Tag:0x00007f5c956a7b30>

My story:
I have a zoo of actors and sensors for Illumination at home. Lamps controlled through homematic actors, Lightify and Tradfri ones. The lights are a mix of dimming, switched (ON/OFF), white temperature and colour ones . Sensors (switches) from HomeMatic traditional and IP with motion detection and without. Motion sensors have and environment Illumination sensor included.

Expectation:

  • As much as possibly uniformity of operation across all type of lights (lamps) and controls (switches, movement sensors).
  • Keep it simple and understandable
  • Support day time scenarios e.g. bed time, tv time, day time

Topics I will touch:

  • Unified switch paradigm (toggle, dimm)
  • Movement initiated lights
  • Day time scenarios
  • Light parameters (min, max, timeouts,…)
  • Implementation

Unified switch paradigm

My switches support following channel actions:

  • SHORT_PRESSED initiates a toggle light action, between ON/OFF or 0 and max dim value for current day time scenario. Switches dimmed light off and if motion light is active turns it on permanent.
  • LONG_PRESSED initiates a dim light action, from 0% to min dim value and increments of dim steps to 100%
  • DOUBLE_PRESSED used for special purposes

Movement initiated lights

Following starting requirements are set:

  • All my movement sensors are set to send a movement ON and timeout after 15s to movement OFF is no movement detected.
  • Managing of movement light timeout is individual per light and done by the rule only
  • Each individual control takes care about illumination thresholds
  • Each individual control adapts light between min/max dim value based on environmental illumination, the darker the lower the dim value
  • Each individual control takes care about day time scenarios (daytime, bedtime, tv time, etc) with corresponding on/off/dim values

Day time scenarios

  • Day Time is the default scenario if no other scenario active
  • Bed Time is a schedule and manual overridden scenario which indicates sleeping or at most visiting the bathroom and being sure that no flashes of motion operated lights would happen therefore no motion light.
  • TV Time is the time when it should be Bed Time, the TV though is still running, getting popcorn or visiting bath room should operate motion lights at lowest possible level, if at all

Light parameters

There are global parameters which are used if no parameter is set on light individual level.

  • Illumination Threshold Value - Maximum value where motion operated light react on movement
  • All Illumination - Illumination is ignored, motion always activates light
  • **All Day Light ** - Day time scenarios are ignored motion always activates light
  • Timeout OFF Time - Time in seconds for a motion operated light to go off if no more motion detected
  • Timeout Forgot Light - Time in seconds a manually toggled/dimmed should go off
  • Minimum Dim Value - Lowest daytime dim values for a dimmed and motion light with lowest illumination
  • Maximum Dim Value - Highest daytime dim values for a dimmed and motion light with highest illumination
  • Bed Time Dim Value - Bed time dim value for a dimmed and motion light with all day enabled or when tv scenario on
  • Dimm Step - Incremental step for dimmed lights for subsequent LONG_PRESSED
  • Double Click Timeout - maximum time between subsequent press to be recognised as double press action

Implementation

Rules do not support library function, therefore I decided to extensively use lambdas. This has some constraints which lambdas imply:

  • Parameters a call by value
  • Only Items are callable as global variables
  • Everything has to be passed as parameter including lambdas calling lambdas
  • Synchronisation of threads not possible by Java locks as try catch final not guaranteed

as a result the implementation is:

  • Using of thread safe hashMap for passing parameters back and forth including thread sync
  • Passing all lambdas as parameters

Items

The isBedTime and isTVOn are managed by Rules which are not listed here, just simple time schedule routines. The light items are just listed for completeness.

import java.util.Map

// Hasmap Key PostFixes not usable in lambdas
/*
val String ITEM = "Light Item  (used in force/cleanup routine)"
val String ITST = "Item State (used in force/cleanup routine)"
val String ITSF = "Item State Force, not yet used"
val String ITIL = "Ilumination Item"
val String ITCT = "Color Temp Item"
val String ITMO = "Motion Item"
val String ILUT = "Illumination Threshold"
val String ALLD = "All Day Light, No Time Limit"
val String ALIL = "All Illumination, No ilumination Limit "
val String TIMR = "Timer"
val String TIEX = "Timer Expiring At"
val String TIMO = "Timeout Motion Light OFF"
val String TOFL = "Timeout Forgot Light"
val String MIDI = "Minimum Dim Value"
val String MADI = "Maximum Dim Value"
val String BTDI = "Bed Time Dimm Value"
val String DIST = "Dimm Step"
val String DCTO = "Double Click Timeout"
val String DCLC = "Double Click Lock" //0=idle, 1=short_press_waiting, 2=in_execution
val String LAST = "Last Action" // 0=off/idle 1=toggle, 2=dimm, 3=motion
*/
val java.util.concurrent.ConcurrentHashMap<String, Object> lightParams = new java.util.concurrent.ConcurrentHashMap()

val hashMapMgr = [ Map<String, Object> lightParams, GenericItem light, String postFix, Object value |
    try{
		// no value supplied, return stored value
		if (value === null){
			// if not value stored for item, return global default value
			return if (lightParams.containsKey(light.label+postFix)) lightParams.get(light.label+postFix) else lightParams.get(postFix)
		} else {
			// set the supplied value to hashMap
			lightParams.put(light.label+postFix, value)
		}
	} catch(Exception e) {
        logInfo("hashMapMgr", " Exception : " + e.getLocalizedMessage)
	}
    return null
]

val lightManager = [ GenericItem light, 
						Number level,
						Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object> hashMapMgr,
                        Map<String, Object> lightParams|
    try{
		// is it a dimmer color type, hadle it through % values
		if (light.acceptedCommandTypes.contains(PercentType) ) {
			val calcLevel = if (level > 0 && isBedTime.state == ON) 
								hashMapMgr.apply(lightParams, light, "BTDI", null) as Number
							else
								if (level > 0 && level < hashMapMgr.apply(lightParams, light, "MIDI", null) as Number) 
									hashMapMgr.apply(lightParams, light, "MIDI", null) as Number
								else
									if (level > hashMapMgr.apply(lightParams, light, "MADI", null) as Number)
										hashMapMgr.apply(lightParams, light, "MADI", null) as Number
									else
										level
							  
			logInfo("lightManager", " <"+light.label + "> : Percent Type - Current Level: " + light.state + " Target Level: " + calcLevel.toString)

			// send target level to dimm/color item
			sendCommand(light, calcLevel)
		}
		else {
			logInfo("lightManager", " <"+light.label + "> : Switch Type - Current Level: " + light.state + " Target Level: "+ level)
			// send ON/OFF to switch item
			sendCommand(light, if (level > 0) ON else OFF) 
		} 
		// store target level for cleanup and enforcement
		hashMapMgr.apply(lightParams, light, "ITST", level)
		hashMapMgr.apply(lightParams, light, "ITEM", light)

	} catch(Exception e) {
        logInfo("lightManager", " Exception : " + e.getLocalizedMessage)
		sendCommand(light, "0")
	}
    return true
]


val lightTimerManager= [ GenericItem light, 
							Number level,
                            Functions$Function4<GenericItem, 
								Number, 
								Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object>,
								Map<String, Object>,
								Boolean> lightMgr, 
                            Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object> hashMapMgr,
                            Map<String, Object> lightParams |
    	try{
		// value for motion or forgot light timeout
        val Number timeout = if (hashMapMgr.apply(lightParams, light, "LAST", null) as Number == 3) 
				hashMapMgr.apply(lightParams, light, "TIMO", null) as Number // motion timeout
			else
				hashMapMgr.apply(lightParams, light, "TOFL", null) as Number // forgot light timeout
        val GenericItem motion = hashMapMgr.apply(lightParams, light, "ITMO", null) as GenericItem

		// is the light currently on
		if (level != 0){
 			// Is there a timer already running for item (e.g. motion) and timers expiration > now
			if ((hashMapMgr.apply(lightParams, light, "TIMR", null)!== null ) && (hashMapMgr.apply(lightParams, light, "TIEX", null) as DateTime  > now )){
				// Is the current running timer timeout shorter that new needed
				if ((hashMapMgr.apply(lightParams, light, "TIEX", null) as DateTime) < now.plusSeconds(timeout.intValue)){
					// yes, so extend timer and store new timeout in hash table
			 		logInfo("lightTimerManager", " <"+light.label + "> : Extend Timer for " + timeout.toString + "s")
					hashMapMgr.apply(lightParams, light, "TIEX", now.plusSeconds(timeout.intValue))
					(hashMapMgr.apply(lightParams, light, "TIMR", null) as Timer).reschedule(now.plusSeconds(timeout.intValue))
				}
			}
			else {
				// either no timer runnimg or expired, create a new one and store new timeout in hash table
				logInfo("lightTimerManager", " <"+light.label + "> : Create Timeout for "+ timeout.toString + "s")
				hashMapMgr.apply(lightParams, light, "TIEX", now.plusSeconds(timeout.intValue))
				hashMapMgr.apply(lightParams, light, "TIMR", 
                createTimer(now.plusSeconds(timeout.intValue), [| 
					if (motion !== null && motion.state == ON) {
						// timer expired motion still active, extend time again, again, ....
        				logInfo("lightTimerManager", "<"+light.label + "> :  Timer Expired,  Though Motion Still Active")
						(hashMapMgr.apply(lightParams, light, "TIMR", null) as Timer).reschedule(now.plusSeconds(timeout.intValue))
						hashMapMgr.apply(lightParams, light, "TIEX", now.plusSeconds(timeout.intValue))
					} 
					else {
						// timer expired turn light off
						logInfo("lightTimerManager", "<"+light.label + "> :  Timer Expired")
						lightMgr.apply(light, 0, hashMapMgr, lightParams)
					}
 				]))
			}
		} else if (hashMapMgr.apply(lightParams, light, "TIMR", null)!== null) {			
			// light turned off externally, button, update/cancel timer update hash table
        	logInfo("lightTimerManager", " <"+light.label + "> : Cancel Timer")
			(hashMapMgr.apply(lightParams, light, "TIMR", null) as Timer).cancel()
			hashMapMgr.apply(lightParams, light, "TIEX", now)
 		}

	} catch(Exception e) {
        logInfo("lightTimerManager", " Exception : " + e.getLocalizedMessage)
		sendCommand(light, "0")
	}
    return true
]


val dimmLight= [ GenericItem light, 
				Functions$Function4<GenericItem, 
									Number, 
									Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object>,
									Map<String, Object>,
									Boolean> lightMgr, 
				Functions$Function5<GenericItem, 
									Number,
									Functions$Function4<GenericItem, 
										Number, 
										Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object>,
										Map<String, Object>, Boolean>, 
									Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object>,
									Map<String, Object>,
									Boolean> lightTimerMgr,
				Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object> hashMapMgr,
				Map<String, Object> lightParams|
try{
		val Number lastOp =  hashMapMgr.apply(lightParams, light, "LAST", null) as Number
		// store dimming als last operation, used by other lamdas
		hashMapMgr.apply(lightParams, light, "LAST", 2)
		val Number level = 
			if (light.getStateAs(OnOffType) == OFF)  
				// is off set minum value, lightmanager takes care about bed time, etc
				hashMapMgr.apply(lightParams, light, "MIDI", null) as Number 
			else 
				// is on, if prevois movement set to max else increment by dimm increment, light manager takes care about bed time limits, etc
				if ( light.getStateAs(OnOffType) == OFF || lastOp == 3) 
					100 
				else 
					hashMapMgr.apply(lightParams, light, "ITST", null) as Number + hashMapMgr.apply(lightParams, light, "DIST", null) as Number
		lightMgr.apply(light, level , hashMapMgr, lightParams)

		// notify time manager to take care about auto off timers
		lightTimerMgr.apply(light, level, lightMgr, hashMapMgr, lightParams)

	} catch(Exception e) { 
        logInfo("toggleLight", " Exception : " + e.getLocalizedMessage)
		sendCommand(light, "0")
	}
    return true
]

val toggleLight= [ GenericItem light, 
					Functions$Function4<GenericItem, 
										Number, 
										Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object>,
										Map<String, Object>,
										Boolean> lightMgr, 
					Functions$Function5<GenericItem, 
										Number,
										Functions$Function4<GenericItem, 
											Number, 
											Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object>,
											Map<String, Object>, Boolean>, 
										Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object>,
										Map<String, Object>,
										Boolean> lightTimerMgr,
                    Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object> hashMapMgr,
                    Map<String, Object> lightParams | 

	// retvalue is used to reset timer if light switched off
    try{
		hashMapMgr.apply(lightParams, light, "LAST", 1)

		// manage double_pressed event
		if (hashMapMgr.apply(lightParams, light, "DCLC", null) !== null){
			// double click enabled for item
			if (hashMapMgr.apply(lightParams, light, "DCLC", null) == 0){
				// no 1st click inaction, lock it a 1st click and create timer to wait for 2nd click
				hashMapMgr.apply(lightParams, light, "DCLC", 1)
				createTimer(now.plusSeconds(hashMapMgr.apply(lightParams, light, "DCTO", null) as Integer), [| 
					// timer expired, did a 2nd click came in, if no lock the 1st click and execute 
					if (hashMapMgr.apply(lightParams, light, "DCLC", null) == 1) {
						hashMapMgr.apply(lightParams, light, "DCLC", 2)
						lightMgr.apply(light, if ( light.getStateAs(OnOffType) == OFF) 100 else 0, hashMapMgr, lightParams)
					} 
					// reset  to initial state
 					hashMapMgr.apply(lightParams, light, "DCLC", 0)
				])
				return true
			} 
			else 
				// 2nd click, check if 1st click still waiting, if so lock it as 2nd click, 
				// else get out and leave it to 1st click to complete
				if (hashMapMgr.apply(lightParams, light, "DCLC", null) == 1) 
					hashMapMgr.apply(lightParams, light, "DCLC", 2)
				else
					return true

		} 
		
		// only reached when no double click enabled or 2nd click came in before 1st click expires
		lightMgr.apply(light, if ( light.getStateAs(OnOffType) == OFF) 100 else 0, hashMapMgr, lightParams)

		// notify time manager to take care about auto off timers
		lightTimerMgr.apply(light, if ( light.getStateAs(OnOffType) == OFF) 100 else 0 ,lightMgr, hashMapMgr, lightParams)

 	} catch(Exception e) {
        logInfo("toggleLight", " Exception : " + e.getLocalizedMessage)
		lightMgr.apply(light, 0, hashMapMgr, lightParams)
	}

	return true
]


val motionOnLight= [ GenericItem light, 
					Functions$Function4<GenericItem, 
										Number, 
										Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object>,
										Map<String, Object>,
										Boolean> lightMgr, 
					Functions$Function5<GenericItem, 
										Number,
										Functions$Function4<GenericItem, 
											Number, 
											Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object>,
											Map<String, Object>, Boolean>, 
										Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object>,
										Map<String, Object>,
										Boolean> lightTimerMgr,
                    Functions$Function4<Map<String, Object>, GenericItem, String, Object, Object> hashMapMgr,
                    Map<String, Object> lightParams|
	
    try{
       	logInfo("motionOnLight", " <"+light.label )
		// enable to override motion on by button on (higher density + longer timeout)
		hashMapMgr.apply(lightParams, light, "LAST", 3)
		val Number iluItemState = (hashMapMgr.apply(lightParams, light, "ITIL", null) as GenericItem).state as Number
		val Number iluLimit = hashMapMgr.apply(lightParams, light, "ILUT", null) as Number
		val Boolean allDay = hashMapMgr.apply(lightParams, light, "ALLD", null) as Boolean
		val Boolean allIll = hashMapMgr.apply(lightParams, light, "ALIL", null) as Boolean
		var Number targetLevel = 0
		
		// Is is not bed time or light night TV or all time light and it is dark enough or ignoring illumination
		if ((isBedTime.state == OFF || isTVOn.state == ON || allDay) && (allIll || (iluItemState) <= iluLimit)) {

			// out of the threshold calculate min 5 and max 100 values
			targetLevel = 5 + ((iluItemState + 5) * 95 / iluLimit)

			// only set value if current light power is lower, timeout extension managed by time manager
			if (light.getStateAs(DecimalType)*100 < targetLevel) lightMgr.apply(light, targetLevel, hashMapMgr, lightParams)
		}		
		
		// notify time manager to take care about auto off timers
		lightTimerMgr.apply(light, targetLevel, lightMgr, hashMapMgr, lightParams)

  	} catch(Exception e) {
        logInfo("motionOnLight", " Exception : " + e.getLocalizedMessage)
 	    sendCommand(light, "0")
  	}
    return true
]

rule "Initiate lightControl.rules"
when
        System started
then
		sendCommand(gColTemp, 100) 
		sendCommand(ToiletLightWallHM, ON) 
		sendCommand(BathRoomLightTopHM, ON) 

        lightParams.put("ITST", 0)
        lightParams.put("TIMO", 60)
		lightParams.put("TOFL", 60*60*12)
        lightParams.put("MIDI", 5)
		lightParams.put("BTDI", 5)
        lightParams.put("MADI", 100)
        lightParams.put("DIST", 25)
		lightParams.put("ALLD", false)
		lightParams.put("ALIL", false)
		lightParams.put("ITSF", false)
		lightParams.put("ILUT", 100)
		lightParams.put("LAST", 0) // idle
		lightParams.put("DCTO", 1)
	
        logInfo("Initiate lightControl.rules", "finished")
end

// ***************************************************************
// ************************ Rules Start **************************
// ***************************************************************


// ***************************************************************
// ************************ Rules Start **************************
// ***************************************************************

rule "lightControl Clean-Up .rules"
when
Time cron "	0 0/5 * 1/1 * ? *"
then
/*
	val lightParamsKeys = lightParams.keySet()

	lightParamsKeys.forEach[ key |
				
			if (key.contains("ITST")){
				var String itemKey = key.replace("ITST", "ITEM")
				itemKey.replace("ITST", "ITEM")
				
				if ((lightParams.get(itemKey) as GenericItem).getStateAs(OnOffType) == ON && lightParams.get(key) as Number  == 0){
					lightManager.apply(lightParams.get(itemKey) as GenericItem, 0, hashMapMgr, lightParams)
					loggInfo("lightControl Clean-Up.rules", " mismatch light: "+ key.replace("ITST", "")+" value: "+ lightParams.get(key).toString)
					logggggDebug("loggerName", "message")
				}
			}
		]

*/
	logInfo("lightControl Clean-Up .rules", "finished")
end


// ***************************************************************
// ************************ Entry/Outside **************************
// ***************************************************************


rule "Initiate Entry-Outside.rules"
when
        System started
then
        lightParams.put(EntryLight.label+"MIDI", 30)
        lightParams.put(EntryLight.label+"ITIL", EntryIlumination)
        lightParams.put(EntryLight.label+"ITMO", EntryMotion)
        lightParams.put(EntryOutsideLight.label+"ALLD", true)
        lightParams.put(EntryOutsideLight.label+"ITIL", EntryOutsideIlumination)
        lightParams.put(EntryOutsideLight.label+"ITMO", EntryOutsideMotion)

        logInfo("Initiate Entry-Outside.rules", "finished")
end

rule "Entry toggle light"
when
	Channel "homematic:HMIP-WRC2:3014F711A0001F58A992FB22:00019709AAA0C6:1#BUTTON" triggered SHORT_PRESSED or 
	Channel "homematic:HMIP-WRC2:3014F711A0001F58A992FB22:00019709AAA0C6:1#BUTTON" triggered DOUBLE_PRESSED
then
		toggleLight.apply(EntryLight, lightManager, lightTimerManager, hashMapMgr, lightParams)
end

rule "Entry Outside toggle light"
when
	Channel "homematic:HMIP-WRC2:3014F711A0001F58A992FB22:00019709AAA0C6:2#BUTTON" triggered SHORT_PRESSED or
	Channel "homematic:HMIP-WRC2:3014F711A0001F58A992FB22:00019709AAA0C6:2#BUTTON" triggered DOUBLE_PRESSED 
then
		toggleLight.apply(EntryOutsideLight, lightManager, lightTimerManager, hashMapMgr, lightParams)
end

rule "Entry dimm light"
when
	Channel "homematic:HMIP-WRC2:3014F711A0001F58A992FB22:00019709AAA0C6:1#BUTTON" triggered LONG_PRESSED or
	Channel "homematic:HMIP-WRC2:3014F711A0001F58A992FB22:00019709AAA0C6:2#BUTTON" triggered LONG_PRESSED or
	Channel "homematic:HmIP-SMI55:3014F711A0001F58A992FB22:0014D709AEF787:1#BUTTON" triggered CONT or
	Channel "homematic:HmIP-SMI55:3014F711A0001F58A992FB22:0014D709AEF787:2#BUTTON" triggered CONT 
then
		dimmLight.apply(EntryLight, lightManager, lightTimerManager, hashMapMgr, lightParams)
end

rule "Entry motion light "
when
    Item EntryMotion changed to ON
then
    motionOnLight.apply(EntryLight, lightManager, lightTimerManager, hashMapMgr, lightParams)
end


Ported to Python NGRE

I invested some time in order to “lift-and-shift” the code from DSL to NGRE.
As the paradigms and setup were ok I stuck to the external definition of rules. Metadata seemed to me no inflexible for the job.

lightControl.py (reduced to my test lights, I have a few dozens:

from core.log import logging, LOG_PREFIX
from core.rules import rule
import imp
import personal.LightControlLib
imp.reload(personal.LightControlLib)
import personal.kitchenCookingVentilationLib
imp.reload(personal.kitchenCookingVentilationLib)
from personal.LightControlLib import lightItemManager
    
log = logging.getLogger(LOG_PREFIX + ".light.control.log") 
                        
'''
Light Parameter Abbreviations
ILUT    Ilumination Threshold 
ALLD    all day light, ignore daytime and other schedule (0/1)
NTOL    nightime only light, on only when bedtime (0/1)
ALIL    no ilimination limit, ignore ilumination threshold (0/1)
TIMO    timeout for motion events
TOFL    timeout forgot light 
MIDI    minimum dim value 
MADI    maximum dim value 
BTDI    bed time dim value 
DIST    dim step 
ONOF    set light type to on/of only (0/1)
DOCL    cleanup and enforce light state (0/1)
CLUT    cleanup and enforce state timer

Trigger Type Abreviations
CRON    CronTrigger** - fires based on cron expression
ISCT    ItemStateChangeTrigger** - fires when the specified Item's state changes
ISUT    ItemStateUpdateTrigger** - fires when the specified Item's state is updated
ICOT    ItemCommandTrigger** - fires when the specified Item receives a Command
GEVT    GenericEventTrigger** - fires when the specified occurs
IEVT    ItemEventTrigger** - fires when am Item reports an event (based on ``GenericEventTrigger``)
TEVT    ThingEventTrigger** - fires when a Thing reports an event (based on ``GenericEventTrigger``)
TSCT    ThingStatusChangeTrigger** - fires when the specified Thing's status changes **(requires S1636, 2.5M2 or newer)**
TSUT    ThingStatusUpdateTrigger** - fires when the specified Thing's status is updated **(requires S1636, 2.5M2 or newer)**
CEVT    ChannelEventTrigger** - fires when a Channel reports an event
DEVT    DirectoryEventTrigger** - fires when a directory's contents changes
IRET    ItemRegistryTrigger** - fires when the specified Item registry event occurs
IADT    ItemAddedTrigger** - fires when an Item is added (based on ``ItemRegistryTrigger``)
IRET    ItemRemovedTrigger** - fires when an Item is removed (based on ``ItemRegistryTrigger``)
IUPT    ItemUpdatedTrigger** - fires when an Item is updated (based on ``ItemRegistryTrigger``)
STUT    StartupTrigger** - fires when the rule is activated **(implemented in Jython and requires S1566, 2.5M2 or newer)**
'''
                                                  
                                 
LIGHT_ATTRIBUTES='''
{
    "TestGroup":
        
        [
                {
                "ONOF" : 0,
                "MIDI" : 1,
                "TIMO" : 10,
                "ITIL" : "iluminationTest"
                }
            ,
            {
                "TOG" : [
                    ["CET", "homematic:HmIP-SMI55:3014F711A061A7D5698CE994:0014D709AEF7C0:1#BUTTON", "SHORT_PRESSED", ""],
                    ["CET", "homematic:HmIP-SMI55:3014F711A061A7D5698CE994:0014D709AEF7C0:2#BUTTON", "SHORT_PRESSED", ""]
                ],
               "DIM" : [
                    ["CET", "homematic:HmIP-SMI55:3014F711A061A7D5698CE994:0014D709AEF7C0:1#BUTTON", "LONG_PRESSED", ""],
                    ["CET", "homematic:HmIP-SMI55:3014F711A061A7D5698CE994:0014D709AEF7C0:2#BUTTON", "LONG_PRESSED", ""]
                ]
            }
        ],
        
    "TestGroupSceneMix":
        
        [
                {
                "ONOF" : 0,
                "MIDI" : 1,
                "TIMO" : 10,
                "ITIL" : "iluminationTest"
                }
            ,
            {
               "MOV" : [
                    ["ISCT", "motionTest", "OFF", "ON"]
                ]
            }
        ],
        
    "testGU10L":
        [
                {
                "ONOF" : 1,
                "MIDI" : 7,
                "ALLD" : 1,
                "ILUT" : 100,
                "ITIL" : "iluminationTest"
                }
            ,
            {
                "TOG" : [
                    ["CET", "deconz:switch:d6dfbf93:ccccccfffe51c077011000:buttoneven", "2002", ""]
                ],
                "DIM" : [
                    ["CET", "deconz:switch:d6dfbf93:ccccccfffe51c077011000:buttoneven", "2001", ""]
                ],
                "MOV" : [
                    ["ISCT", "motionTestOFF", "OFF", "ON"]
                ]
            }
        ],
    "testGU10M":
            [
            {
                "ONOF" : 0,
                "MIDI" : 7,
                "ITIL" : "iluminationTest"
            } 
            ,
            {
                "TOG" : [
                    ["CET", "deconz:switch:d6dfbf93:ccccccfffe51c077011000:buttoneven", "1002", ""]
                ],
                "DIM" : [
                     ["CET", "deconz:switch:d6dfbf93:ccccccfffe51c077011000:buttoneven", "1001", ""]
                ],
                "MOV" : [
                    ["ISCT", "motionTestOFF", "OFF", "ON"]
                ]
            }
        ]
}
'''
             
    
lightsMgr = lightItemManager(LIGHT_ATTRIBUTES)
              
@rule("Light Manager Rule", description="This is an rule for controlling lights")   
class LightManagerRule(object):    
    def __init__(self):   
        self.triggers = lightsMgr.getAllTriggers()    

    def execute(self, module, inputs):    
        lightsMgr.execute(module, inputs)        

def scriptUnloaded():
    log.info("************* Unload ********* ")              
#    lightsMgr.cleanUpTimer()                

lightControlLib.py located in lib/personal



# edit 23-03
from core.triggers import ChannelEventTrigger, ItemEventTrigger, ItemStateChangeTrigger
#from core.actions import ScriptExecution
#from org.joda.time import DateTime
from core.log import logging, LOG_PREFIX
from threading import Timer
import yaml
from core.jsr223.scope import events, items, itemRegistry, PercentType#, OnOffType, IncreaseDecreaseType, RefreshType

import inspect
import traceback


'''
Light Parameter Abbreviations
ILUT    Ilumination Threshold 
ALLD    all day light, ignore daytime and other schedule (0/1)
NTOL    nightime only light, on only when bedtime (0/1)
ALIL    no ilimination limit, ignore ilumination threshold (0/1)
TIMO    timeout for motion events
TOFL    timeout forgot light 
MIDI    minimum dim value 
MADI    maximum dim value 
BTDI    bed time dim value 
UDDI    up/down dimm enable    
DIST    dim step 
ONOF    set light type to on/of only (0/1)
DOCL    cleanup and enforce light state (0/1)
CLUT    cleanup and enforce state timer
CTON    color temperature on
CTVL    color temperature value
ITCT    item color temperature
ITIL    item ilumination
ITTR    triggering item

Trigger Type Abreviations
CRON    CronTrigger** - fires based on cron expression
ISCT    ItemStateChangeTrigger** - fires when the specified Item's state changes
ISUT    ItemStateUpdateTrigger** - fires when the specified Item's state is updated
ICOT    ItemCommandTrigger** - fires when the specified Item receives a Command
GEVT    GenericEventTrigger** - fires when the specified occurs
IEVT    ItemEventTrigger** - fires when am Item reports an event (based on ``GenericEventTrigger``)
TEVT    ThingEventTrigger** - fires when a Thing reports an event (based on ``GenericEventTrigger``)
TSCT    ThingStatusChangeTrigger** - fires when the specified Thing's status changes **(requires S1636, 2.5M2 or newer)**
TSUT    ThingStatusUpdateTrigger** - fires when the specified Thing's status is updated **(requires S1636, 2.5M2 or newer)**
CEVT    ChannelEventTrigger** - fires when a Channel reports an event
DEVT    DirectoryEventTrigger** - fires when a directory's contents changes
IRET    ItemRegistryTrigger** - fires when the specified Item registry event occurs
IADT    ItemAddedTrigger** - fires when an Item is added (based on ``ItemRegistryTrigger``)
IRET    ItemRemovedTrigger** - fires when an Item is removed (based on ``ItemRegistryTrigger``)
IUPT    ItemUpdatedTrigger** - fires when an Item is updated (based on ``ItemRegistryTrigger``)
STUT    StartupTrigger** - fires when the rule is activated **(implemented in Jython and requires S1566, 2.5M2 or newer)**
'''


log = logging.getLogger(LOG_PREFIX + ".light.control.log") 

def log_trace_back():
    frame = inspect.currentframe()
    stack_trace = traceback.format_stack(frame)
    log.info(''.join(stack_trace))    
        


LIGHT_GLOB_SETTING = '''
{
    "ILUT" : 120,
    "ALLD" : 0,
    "NTOL" : 0,
    "ALIL" : 0,
    "TIMO" : 60,
    "TOFL" : 86400,
    "UDDI" : 0,
    "MIDI" : 5,
    "MADI" : 100,
    "BTDI" : 5,
    "DIST" : 15,
    "ONOF" : 0,
    "DOCL" : 1,
    "CLUT" : 120,
    "CTVL" : 100,
    "CTON" : 0,
    "ITCT" : "None",
    "ITIL" : "None",
    "ITTR" : "None"
}
'''

class lightTimer(object):
    """
    Class for managing the item (light) timeouts:

    - item specific timeouts
    - celanup of running timers
    """
    def __init__(self):

        self.timer = None
    
    def start(self, time_out, lambda_fnc):
        """
        start timeout for item (light):
        - toggle (TOG) dimming (DIM) -> timeout forgot light (TOFL)
        - movement light -> timeout movemet (TIMO)
        """
        #if a time already running, cancel it
        if self.timer is not None:
            self.timer.cancel()

        # set and start timer
        self.timer = Timer(time_out, lambda_fnc)
        self.timer.start()

    def cancel(self):
        if self.timer != None:
            self.timer.cancel()

class lightAttributes(object):
    """
    Class for managing the item (light) attributes:

    - item specific attributes
    - global attributes
    - gui changable attributes
    """
    def __init__(self, attr):
        """
        initiate item specific and global attributes
        """
        self.attr = attr
        
        # set default attr values from LIGHT_DEFAULTS group members
        self.global_attr = yaml.safe_load(LIGHT_GLOB_SETTING)
        # attributes managed trhough UI
        self.ui_default_attr = {}


 
    def getGlobAttr(self, attr_name):
        """
        return globaly defined attribute
        """
        return int(str(self.global_attr[attr_name]))

    def getItemAttr(self, attr_name):
        """
        return item specific attribute if exist
         else
        return the globaly defined attribute
        """
        if str(attr_name) in self.attr:
            return int(self.attr[attr_name]) if str(self.attr[attr_name]).isnumeric() else self.attr[attr_name]
        else:
            return int(self.global_attr[attr_name]) if str(self.global_attr[attr_name]).isnumeric() else self.global_attr[attr_name]

    def setItemAttr(self, attr_name, value):
        """
        set item specific attribute 
         else
        return the globaly defined attribute
        """
        self.attr[attr_name] = value
 
    def loadUIDefaults(self):
        """
        load UI defined attributes over global attributes
        """
        self.ui_default_attr = {str(item.name).split("_")[2] :item.state for item in itemRegistry.getItem("LIGHT_DEFAULTS").members} 

class lightItem(object):
    """
    Class for managing the item (light) states

    - reaction on trigger comming from the basic rule
    - cleanup of lazy/not reacting lights
    - respecting daytime schedules 
    - respecting environmental ilimination
    - implementing timeouts for movement events and forgot light
    """
    def __init__(self, item_name, attr):
        """
        
        """
        self.item_name = item_name
        self.light_attrs = lightAttributes(attr)
        # stored ligt states (0..100, ON/OFF)
        self.stored_items_state = 0
        # item  retry counter
        self.item_retry_count = 0
        # item act type (None/DIM/TOG/MOV)
        self.item_act_type = "None"
        # item dimm direction (UP/DOWN)
        self.item_dim_dir = "UP"
        # item on/off type
        self.is_on_off = self.light_attrs.getItemAttr("ONOF") == 1 or PercentType not in itemRegistry.getItem(self.item_name).acceptedCommandTypes

        # initiale timers for all lights
        self.light_tmr = lightTimer()
        self.light_dim_toggle_tmr = lightTimer()


    def enforceON(self, init):
        # cancel all running timers
        if init:
            self.light_tmr.cancel()
            self.item_retry_count = 0

        if self.stored_items_state != self.getState():
            events.sendCommand(self.item_name, "ON" if self.is_on_off else str(self.stored_items_state))
            self.item_retry_count = self.item_retry_count + 1
            if self.item_retry_count < 10:
                self.light_tmr.start(3, lambda: self.enforceON(False))
                return

        # timeout value based on action/trigger type
        time_out = self.light_attrs.getItemAttr("TIMO") if self.item_act_type == "MOV" else self.light_attrs.getItemAttr("TOFL")
        self.light_tmr.start(time_out, lambda: self.setState(0))


    def enforceOFF(self, init):
        # cancel all running timers

        try:
            if init:
                self.light_tmr.cancel()
                self.item_retry_count = 0

            # if triggering item still on, leave the light on

            if self.light_attrs.getItemAttr("ITTR") != "None" and str(items[self.light_attrs.getItemAttr("ITTR")]) == "ON" and self.item_act_type == "MOV" :
                self.light_tmr.start(self.light_attrs.getItemAttr("TIMO"), lambda: self.enforceOFF(True))
                return

            if self.stored_items_state != self.getState():
                events.sendCommand(self.item_name, "OFF" if self.is_on_off else "0")
                self.item_retry_count = self.item_retry_count + 1
                if self.item_retry_count < 10:
                    self.light_tmr.start(self.item_retry_count * 5, lambda: self.enforceOFF(False))
        except:
            self.item_act_type = "None"
 


    def setState(self, state):
        """
        set state of openhab item (itm) 
        and
        set state of item in local class db 
        """


        #limit the state to 0/100 regardless of type
        state = max(0, min(100, int(state)))

        # store item state in class db if is on/off type 0/100 are possible values
        if self.is_on_off and state > 0 :
            self.stored_items_state = 100 
        else:
            self.stored_items_state = state

        if state > 0:
            # set color temperature is enabled
            if self.light_attrs.getItemAttr("CTON") == 1 : 
                events.sendCommand(self.light_attrs.getItemAttr("ITCT"), str(self.light_attrs.getItemAttr("CTVL")))
            self.enforceON(True)
        else:
            self.enforceOFF(True)



    def getState(self):
        """
        get state of the openhab item (itm) 
        """
        try:
            if  self.is_on_off:
                return 100 if str(items[self.item_name]) == "ON"  or str(items[self.item_name]) == "100" else 0
            else:
                return items[self.item_name].intValue()
        except:
            return 0

    def isLightOnSchedule(self):
        """
        Dtermine if the light should be switch on based on:
        - daytime schedule (BedTime)
        """

        if self.light_attrs.getItemAttr("NTOL") == 1:
            return True if str(items["isBedTime"]) == "ON" else False
        else:
            return self.light_attrs.getItemAttr("ALLD") == 1 or str(items["isBedTime"]) == "OFF" 

    def isLightRequired(self):
        """
        Determine if the light should be switch on:
        - brightnes/illumination of the environment.
        """
        return True if self.light_attrs.getItemAttr("ALIL") == 1 else items[self.light_attrs.getItemAttr("ITIL")].floatValue() < float(self.light_attrs.getItemAttr("ILUT"))

    def getLightLevel(self, ilumin_aware):
        """
        Determine if the light level based on:
        - brightness of environment
        - daytime schedule.
        """
        
        #if ilumination aware, set value based of environment ilumination
        if ilumin_aware:
            target_level = 100 if self.light_attrs.getItemAttr("ALIL") == 1 else 5 + ((items[self.light_attrs.getItemAttr("ITIL")].floatValue() + 5 )*95 / self.light_attrs.getItemAttr("ILUT"))

        # if it is bedtime, return the bedtine level
        if str(items["isBedTime"]) == "ON":
            target_level = self.light_attrs.getItemAttr("BTDI")   

        # make sure max dim value is maintained
        if self.light_attrs.getItemAttr("MADI") < target_level:
            target_level = self.light_attrs.getItemAttr("MADI") 

        return target_level

    def setDimDir(self, dir):
        self.item_dim_dir = dir

    def getDimLevel(self):
        if self.light_attrs.getItemAttr("UDDI") == "1":
            self.light_dim_toggle_tmr.cancel()
            if self.item_dim_dir == "UP":
                self.light_dim_toggle_tmr.start(1.5, lambda: self.setDimDir("DOWN"))
                return str(self.getState() + self.light_attrs.getItemAttr("DIST"))
            else:
                self.light_dim_toggle_tmr.start(1.5, lambda: self.setDimDir("UP"))
                return str(self.getState() - self.light_attrs.getItemAttr("DIST"))
        else:
            return self.light_attrs.getItemAttr("MIDI") if self.getState() == 0 else self.getState() + self.light_attrs.getItemAttr("DIST")
            

    def togLight(self):
        """
        React on toggle light trigger and toggle:
        - 100
        - 0
        light level
        """
        self.item_act_type = "TOG"
        if self.getState() == 0:
            self.setState(self.getLightLevel(False))
        else:
            self.setState(0)

    def dimLight(self):
        """
        React on dim light trigger an increase light level:
        - by dim step (DIST) defined for light
        - by global dim step if not defined for light
        """
        self.item_act_type = "DIM"
        self.setState(self.getDimLevel())
 

    def movLight(self, trig_itm):
        """
        React on moving light trigger:
        - turn on light for defined time
        - extend the time is movement reocures
        """
        if self.item_act_type != "MOV" and self.getState() != 0:
            return

        self.item_act_type = "MOV"
        self.light_attrs.setItemAttr("ITTR", trig_itm)

        if self.isLightOnSchedule() and self.isLightRequired():
            self.setState(self.getLightLevel(True))
            

    def cleanUpTimer(self):
        """
        Cleanup all running timers by caling "cancel()"
        """

        self.light_tmr.cleanUpTimer()

class lightItemManager(object):
    """
    Class for managing the items (lights):

    - forwarding the events
    - creating the trigger definition for the rule class
    - cleanup timers by calling involved classes
    """
    def __init__(self, attrJsn):

        # initiate light items
        all_attr = yaml.safe_load(attrJsn)
        all_items = [item  for (item, atr) in  yaml.safe_load(attrJsn).items() ]

        self.light_items = { itm: lightItem(itm, all_attr[itm][0]) for itm in all_items }

        # set trigger values for lights
        self.all_trig = { item : attributes[1] for (item, attributes) in  yaml.safe_load(attrJsn).items() }

 
    def execute(self, module, inputs):
        act = str(inputs["module"]).split("_")[0]
        itm = str(inputs["module"]).split("_")[1]
        if act == "TOG":
            self.light_items[itm].togLight()

        if act == "DIM":
            self.light_items[itm].dimLight()

        if act == "MOV":
            self.light_items[itm].movLight(inputs['event'].getItemName())

        if act == "GLOB":
            self.light_attrs.loadUIDefaults()

    def cleanUpTimer(self):
        self.light_state.cleanUpTimer()
        

    def getAllTriggers(self):
        # get triggers from json defintion
        raw_trigger = [[trigger[0], trigger[1], trigger[2], trigger[3], key_item_mode+"_"+key_item]for (key_item, item_all_triggers) in self.all_trig.items() for (key_item_mode, item_mode_triggers) in item_all_triggers.items() for trigger in item_mode_triggers]
        all_trig = []
        for trigger in raw_trigger:
            if trigger[0] == "CET":
                all_trig.append(ChannelEventTrigger(trigger[1], trigger[2], trigger[4]).trigger)
            elif trigger[0] == "ISCT":
                all_trig.append(ItemStateChangeTrigger(trigger[1], trigger[2], trigger[3], trigger[4]).trigger)
            else:
                log.error("********* {} ********* is not implemented yet".format(trigger[0]))

        # add trigger for global defaults
        all_trig.append(ItemEventTrigger("LIGHT_DEFAULTS", "GroupItemStateChangedEvent", "", "GLOB_XXX").trigger)
        return all_trig
         

Feel free to comment.

9 Likes

Thanks for your example! I’m a programmer myself. (IBM ISeries). So, no java, javascript, or whatever…
So, mostly I have to look for examples a start from there. Your solution is a good start for me.

Added latest ported version