Finding auto generated "Flight to x" events from google calendar to schedule tasks in openhab

Tags: #<Tag:0x00007f2fb64cc1f0> #<Tag:0x00007f2fb64cc0b0>

Hello there !

My husband and me are traveling often and often on short notice for work. In Florida cooling costs are a concern. And when we are rushing out the door to catch a flight, turning the AC to humidistat and making sure all the lights are turned off can be easily forgotten.

Our openHab set-up tracks separately if we are at home, just “away” for a short while (i.e. at work, shopping etc), or “gone” (i.e. on vacation or a work trip), combines this information and then controls AC, lights etc accordingly. It takes 26 hrs without a ping of our cellphones to go from at home to gone. So some time ago I wrote a rule that used filtered Caldav events for the events that google mail auto-generates for flight confirmations in our google mail accounts to speed up the process.
With us upgrading to openHAB 2.5.0.M2 Milestone Build, the CalDav binding finally stopped working completely for us so we switched to a slightly hacked version of @davorf 's Python script to get events from google calendar and a completely rebuilt version of the rule as the script doesn’t allow for the filtering function the CalDav binding provided.

So here it goes:

Calendar Script

The husband modified Davorf’s to include an additional field that indicates which calendar an event comes from in a multi calendar set-up. Here is the changed version on Git-hub. We followed the installation instructions in Davorf’s post. The new field takes the calendar name from the CalSyncHab.ini file

[CalendarIDs]
<Calendarname>=<calendar ID>

and puts it into an additional item for each event

String <ItemPrefix>Event<Number>_CalId

that needs to be created for each event up to your maximum count.
Note that having more than 9 events will potentially break the rule as sortBy(name) will sort Event10 in front of Event1. Our setup uses 9 events. Here our CalSyncHab.ini

[General]
ApplicationName: Openhab Calendar

[Calendar]
Scope: https://www.googleapis.com/auth/calendar.readonly
MaxEvents: 9
TimeZone: -04:00
ClientSecretFile: /etc/openhab2/scripts/CalSyncHAB/CalSyncHABSecret.json

[CalendarIDs]
Alex =xxx@gmail.com
Mat=xxx@gmail.com


[OpenHAB]
HostName: xxx.xxx.xxx.xxx
Port: 8080
ItemPrefix: gCal_

Item files

schedule.items

Group gCalEvent
Group gCal

String  gCal_Event1 	(gCal)
String  gCal_Event2 	(gCal)
String	gCal_Event3 	(gCal)
String  gCal_Event4 	(gCal) 
String  gCal_Event5 	(gCal)
String  gCal_Event6 	(gCal)
String  gCal_Event7 	(gCal)
String  gCal_Event8 	(gCal)
String  gCal_Event9		(gCal)


String     gCal_Event1_Summary "Event1 Sum.: [%s]" <calendar> (gCalEvent)
String     gCal_Event1_Location "Event1 Loc.: [%s]" <calendar> (gCalEvent) 
String     gCal_Event1_Description "Event1 Desc. [%s]" <calendar> (gCalEvent) 
DateTime   gCal_Event1_StartTime  "Event1 Start [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
DateTime   gCal_Event1_EndTime "Event1 End [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
String     gCal_Event1_CalId "Event1 Source Calendar [%s]" <calendar> (gCalEvent)

String     gCal_Event2_Summary "Event2 Sum.: [%s]" <calendar> (gCalEvent)
String     gCal_Event2_Location "Event2 Loc.: [%s]" <calendar> (gCalEvent)
String     gCal_Event2_Description "Event2 Desc. [%s]" <calendar> (gCalEvent)
DateTime   gCal_Event2_StartTime  "Event2 Start [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
DateTime   gCal_Event2_EndTime "Event2 End [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
String     gCal_Event2_CalId "Event2 Source Calendar [%s]" <calendar> (gCalEvent)

String     gCal_Event3_Summary "Event3 Sum.: [%s]" <calendar> (gCalEvent)
String     gCal_Event3_Location "Event3 Loc.: [%s]" <calendar> (gCalEvent)
String     gCal_Event3_Description "Event3 Desc. [%s]" <calendar> (gCalEvent)
DateTime   gCal_Event3_StartTime  "Event3 Start [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
DateTime   gCal_Event3_EndTime "Event3 End [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
String     gCal_Event3_CalId "Event3 Source Calendar [%s]" <calendar> (gCalEvent)

String     gCal_Event4_Summary "Event4 Sum.: [%s]" <calendar> (gCalEvent)
String     gCal_Event4_Location "Event4 Loc.: [%s]" <calendar> (gCalEvent)
String     gCal_Event4_Description "Event4 Desc. [%s]" <calendar> (gCalEvent)
DateTime   gCal_Event4_StartTime  "Event4 Start [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
DateTime   gCal_Event4_EndTime "Event4 End [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
String     gCal_Event4_CalId "Event4 Source Calendar [%s]" <calendar> (gCalEvent)

String     gCal_Event5_Summary "Event5 Sum.: [%s]" <calendar> (gCalEvent)
String     gCal_Event5_Location "Event5 Loc.: [%s]" <calendar> (gCalEvent)
String     gCal_Event5_Description "Event5 Desc. [%s]" <calendar> (gCalEvent)
DateTime   gCal_Event5_StartTime  "Event5 Start [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
DateTime   gCal_Event5_EndTime "Event5 End [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
String     gCal_Event5_CalId "Event5 Source Calendar [%s]" <calendar> (gCalEvent)

String     gCal_Event6_Summary "Event6 Sum.: [%s]" <calendar> (gCalEvent)
String     gCal_Event6_Location "Event6 Loc.: [%s]" <calendar> (gCalEvent)
String     gCal_Event6_Description "Event6 Desc. [%s]" <calendar> (gCalEvent)
DateTime   gCal_Event6_StartTime  "Event6 Start [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
DateTime   gCal_Event6_EndTime "Event6 End [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
String     gCal_Event6_CalId "Event6 Source Calendar [%s]" <calendar> (gCalEvent)

String     gCal_Event7_Summary "Event7 Sum.: [%s]" <calendar> (gCalEvent)
String     gCal_Event7_Location "Event7 Loc.: [%s]" <calendar> (gCalEvent)
String     gCal_Event7_Description "Event7 Desc. [%s]" <calendar> (gCalEvent)
DateTime   gCal_Event7_StartTime  "Event7 Start [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
DateTime   gCal_Event7_EndTime "Event7 End [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
String     gCal_Event7_CalId "Event7 Source Calendar [%s]" <calendar> (gCalEvent)

String     gCal_Event8_Summary "Event8 Sum.: [%s]" <calendar> (gCalEvent)
String     gCal_Event8_Location "Event8 Loc.: [%s]" <calendar> (gCalEvent)
String     gCal_Event8_Description "Event8 Desc. [%s]" <calendar> (gCalEvent)
DateTime   gCal_Event8_StartTime  "Event8 Start [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
DateTime   gCal_Event8_EndTime "Event8 End [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
String     gCal_Event8_CalId "Event8 Source Calendar [%s]" <calendar> (gCalEvent)

String     gCal_Event9_Summary "Event9 Sum.: [%s]" <calendar> (gCalEvent)
String     gCal_Event9_Location "Event9 Loc.: [%s]" <calendar> (gCalEvent)
String     gCal_Event9_Description "Event9 Desc. [%s]" <calendar> (gCalEvent)
DateTime   gCal_Event9_StartTime  "Event9 Start [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
DateTime   gCal_Event9_EndTime "Event9 End [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" <calendar> (gCalEvent)
String     gCal_Event9_CalId "Event9 Source Calendar [%s]" <calendar> (gCalEvent)



Group       Alex_Flight_Group
String      Alex_Flight_Origin          "Departure airport of Alex' next flight [%s]"           (Alex_Flight_Group)
String      Alex_Flight_Destination     "Destination airport of Alex' next flight [%s]"         (Alex_Flight_Group)
DateTime    Alex_Flight_Departure       "Departure time of Alex' next flight [%1$td.%1$tm.%1$tY %1$tH:%1$tM]"    <calendar>     (Alex_Flight_Group)
DateTime    Alex_Flight_Landing         "Arrival time of Alex' next flight [%1$td.%1$tm.%1$tY %1$tH:%1$tM]"      <calendar>     (Alex_Flight_Group)
String      Alex_Flight_Type            "Flight type of Alex' next flight [%s]"         (Alex_Flight_Group)
String      Alex_Flight_Number          "Flight Code for Alex' next flight [%s]"        (Alex_Flight_Group)

Group       Mat_Flight_Group
String      Mat_Flight_Origin          "Departure airport of Mat' next flight [%s]"           (Mat_Flight_Group)
String      Mat_Flight_Destination     "Destination airport of Mat' next flight [%s]"         (Mat_Flight_Group)
DateTime    Mat_Flight_Departure       "Departure time of Mat' next flight [%1$td.%1$tm.%1$tY %1$tH:%1$tM]"    <calendar>     (Mat_Flight_Group)
DateTime    Mat_Flight_Landing         "Arrival time of Mat' next flight [%1$td.%1$tm.%1$tY %1$tH:%1$tM]"      <calendar>     (Mat_Flight_Group)
String      Mat_Flight_Type            "Flight type of Mat' next flight [%s]"         (Mat_Flight_Group)
String      Mat_Flight_Number          "Flight Code for Mat' next flight [%s]"        (Mat_Flight_Group)

Switch RefreshCalendar

occupancy.items

Group FamilyG
String Alex (FamilyG)
String Mat (FamilyG)

Number Alex_OSM "Alex Occupancy State [%d]" (House_OSM)
Number Mat_OSM "Mat Occupancy State [%d]" (House_OSM)
Number Guest_OSM (House_OSM)

Group:Number:MIN House_OSM

Rule

import org.eclipse.smarthome.model.script.ScriptServiceUtil
import java.util.Map

// LIST OF HOME AIRPORTS
//adjust this list to reflect which airports you are normally using as your "Home Airport".
//Due to the way google auto generates its events the first list has to include the Airport code the second is without.

val homeairports = newArrayList("Orlando MCU","Fort Lauderdale FLL","Miami MIA","Melbourne MLB")
val homeairports2= newArrayList("Orlando","Fort Lauderdale","Miami","Melbourne")

// map to keep track of the unnamed timers for all involved persons
val Map<String, Timer> timers = newHashMap

   /***********************************************/
  /* GetCalEvents retrieves your google calendar */
 /* events via CalSynHAB.py every 30 minutes    */
/***********************************************/
rule "GetCalEvents"
when
	Time cron "30 0/30 * * * ?" or 
	Item RefreshCalendar changed to ON
then
	var String results = executeCommandLine("/etc/openhab2/scripts/CalSyncHAB.sh",5*1000)
	logInfo("GetCalEvents", results)
	RefreshCalendar.postUpdate(OFF)
end

   /************************************************/
  /* Check for Flight Even						  */
 /*  every 30 minutes 1 minute after the calendar*/
/************************************************/

rule "Check Calendar for Special Events"
when
	Time cron "31 0/30 * * * ?"  or 
	Item testrule changed to ON
then
	logInfo("Calender", "Calendar events updated. Starting search for special events")
		
	// find first flight event for each calendar/person
	FamilyG.members.forEach[ mem | 	
	val event = gCal.members.filter[ i | gCalEvent.members.filter[ act |
			act.name == i.name+"_Summary"].head.state.toString.split(" ").get(0) == "Flight"].sortBy[ name ].sortBy[name].findFirst[ gc | 
			gCalEvent.members.filter[ a | a.name == gc.name+"_CalId"].head.state.toString == mem.name.toString] 
	    
	//create a shorthand variable for the origin calendar/person
	val person = mem.name.toString
	
		if (event !== null) {				
	// cancel previous timer for this person/calendar
			if (timers.get(person) !== null) {
        		timers.get(person).cancel()
        		timers.remove(person)
				logInfo("Calendar", "Cancelled previous timer")
    			}

	// for each event describing a flight get the correct items	
			val eventSummary = gCalEvent.members.filter[ a |
				a.name == event.name+"_Summary"].head.state
			val origin = gCalEvent.members.filter[ a |
				a.name == event.name+"_Location"].head.state
			val departure = gCalEvent.members.filter[ a |
				a.name == event.name+"_StartTime"].head.state
			val landing = gCalEvent.members.filter[ a |
				a.name == event.name+"_EndTime"].head.state

	//		val description = gCalEvent.members.filter[ a |
	//			a.name == event.name+"_Description"].head.state
	//Description is currently unused but could be easily used to get the link to the original email etc.			

    // do some string manipulation to get the departure airport and flight number from event description         
        	var String tempstring = eventSummary.toString.replace("(",",").split(",").get(0).trim()
			var String flightNumber = eventSummary.toString.replace("(",",").split(",").get(1).replace(")"," ").trim()
        	var String destination = tempstring.substring(9,tempstring.length())
		
			logInfo("Calendar", "Found Flight from "+ origin + " to " + destination + " Flight Number (" + flightNumber + ") for " + person)

	//set flight items
			ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Origin").postUpdate(origin)
			ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Destination").postUpdate(destination)
			ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Departure").postUpdate(departure)
			ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Landing").postUpdate(landing)
			ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Number").postUpdate(flightNumber)

	// Find out if the next flight is an outgoing flight (leaving from one of your home airports)
			if (homeairports.contains(origin) == true){
				ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Type").postUpdate("out")
				logDebug("Calendar","Found outgoing flight")
				var DateTime starttime = parse(departure.toString)
				//create execution timer
		
        		timers.put(person,createTimer(starttime, [ |
            	// Here your actions to be started with the departure of an outgoing flight
				// In this case a message is logged and the occupancy state machine for the correct person set to 3 = gone.
					ScriptServiceUtil.getItemRegistry.getItem(person+"_OSM").postUpdate(3)
            		timers.put(person, null)
					logInfo("Calendar",person +"'s flight is departing from " + origin+". Setting their state to GONE")
        		]))
			}
	// If it was not an outgoing flight. Check if the flight goes to one of your home airports and is an incoming flight.		
			else if (homeairports2.contains(destination) == true){
				ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Type").postUpdate("in")
			
				//create execution timer
				var DateTime starttime = parse(landing.toString)
        		timers.put(person,createTimer(starttime, [ |
            	// Here your actions to be started with the landing of an incoming flight
				// In this case a message is logged and the occupancy state machine for the correct person set to 2= away.
					ScriptServiceUtil.getItemRegistry.getItem(person+"_OSM").postUpdate(2)
            		timers.put(person, null)
					logInfo("Calendar",person +"'s flight is landing in " + destination+". Setting their state to AWAY")
        			]))
				logDebug("Calendar","The flight is incoming")
			}
	// 	If no home airport is involved it must be a connecting flight. In this case no action is scheduled.	
			else {
				ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Type").postUpdate("connecting")
				logDebug("Calendar","The flight is connecting")
			}
		}
		//If no flight events are currently found on the calendar: clear old info.
        else {
			
			ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Origin").postUpdate("none")
			ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Destination").postUpdate("none")
			//ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Departure").postUpdate("none")
			//ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Landing").postUpdate("none")
			ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Type").postUpdate("none")
			ScriptServiceUtil.getItemRegistry.getItem(person+"_Flight_Number").postUpdate("none")

			logInfo("Calendar","Cancelled old flight timer for "+ person.toString)
		}
		logDebug("Calendar", "finished for " + person)
    ]
	logInfo("Calender", "End of search for special events")
	
end

The “GetCalEvents” Rule is from the example file that came with the CalSyncHab script.

The “check calendar for special events rules” is heavily based on various of @rlkoshak 's Design Patterns most importantly Working with Groups. Thanks for all those amazing explanations! I would be completely lost in Rules DSL without them.

Hope this helps somebody in their set-up and experiments. I am looking forward to hearing your thoughts.

3 Likes

Great post! Thanks for sharing. This is a really good example of working with GCal and parsing calendar events in Rules. I’m certain many will find it very useful.

Everything looks good, I’ve only a couple of minor suggestions which will hopefully help simplify some of the code and make it easier for future you to come along and maintain it.

  • The find first flight event for each calendar/person packs a whole lot of complexity in a mere four lines of code. Consider splitting that long continuous line of filters into multiple lines. That might make it easier to follow what it’s doing. Don’t be shy about adding comments explaining it either. Future you will thank you for them. :wink:

  • You should use findFirst instead of filter/head. It’s shorter and marginally more efficient.

  • You have .sortBy[ name ] twice.

  • Be diligent with your indents. Every new line in after a new context (i.e. {, [, or () should be indented until the closing of that context. It makes it much easier to follow what code goes with what. This may have been just an artifact of mixing spaces and tabs in your editor if it looks find in VSCode for you.

  • There is one good place where the postUpdate and sendCommand Actions are appropriate for use, when you have the name of an Item but not the Item itself. You don’t need to pull person+"_Flight_Origin" et. al. out of the ItemRegistry to send it an update. Just call postUpdate(person+_Flight_Origin", origin).

1 Like

Thank you for the hints!

The last one will be especially useful as I am in the process of rewriting most of my rules to make better use of groups, lambdas and anything else I learned about in the mean time.

I didn’t even consider parsing the item directly in postUpdate. :woman_facepalming:

Sorry to bother you again, but I have been trying to figure it out and just can’t seem to make any headway.

I’ve been trying to wrap my head around this, so far unsuccessful. I think my issue stems from me not really understanding the functions fully. I don’t have a java background and the various layers of documentation often confuse me (Rules documentation/forum vs Xtend documentation vs Java documentation) and I have to revert back to experimenting.

Originally I had pretty much only copied and pasted my way through these group operations. I ignored “head” completely and it just seemed to work. After your comment I read more and if I understand this right “head” just returns the value from the top of the list. Coincidentally does that mean there is a tail or last as well?

So I figured what I actually want is filter without head because I want all the results and in this case the order doesn’t matter yet, the subset is then further defined later. And that is exactly what I seem to be getting, with “head” that is. Without it, I get an error? Is it not possible to use filter without head?

If my understanding is correct I see how findFirst works like filter/head, what I don’t understand is how that can work logically for my use case.

There are two groups. The first gCal just holds items for name manipulations. They are basically Ids for a certain dataset. The second group gCalEvent holds all the fields (items) for all the ids.

I do the following 2 steps for all calendars or persons I am tracking:

  1. I am looking for all Ids that fulfill the requirement that the event summary contains the word flight.

  2. From this subset I am now taking the first one where the CalId is matching the current person I am looking for.

In both cases I am using filter because I want the whole set of matches returned rather than just the first one. If only the first one was returned for 1. it might be I am being returned a flight summary for a different person and 2. will fail. Basically the same applies to the filter inside the findFirst.

I have (untested) come up with something like this:

FamilyG.members.forEach[ mem | 	
	//for each member of group FamilyG
	val event = gCal.members.findFirst[ e | 
        gCalEvent.members.filter[ a | 
            a.name == e.name+"_CalId".state.toString == mem.name.toString] &&  
        filter[ act |
	    act.name == e.name+"_Summary"].state.toString.split(" ").get(0) == "Flight"
        ]
    ]

but this is only marginally cleaner still containing both filters.

Where is my misunderstanding?

Never a bother.

Correct head returns the first element of the List and correct, there is a tail method.

OK, then indeed you do want to use filter. findFirst returns the first element of the List that matches the condition. filter returns a List of all the elements of the List that matches the condition. It is possible to use filter without using head, but because the result of the filter is a List it only supports methods that are on a List. So if you tried to use:

val event = gCal.members.filter[ i | gCalEvent.members.filter[ act |
		act.name == i.name+"_Summary"].state.toSt...

you get an error because a List doesn’t have a .state. This is one reason why I recommended splitting that one line into multiple intermediate lines. I still can’t say I understand what it does.

this statement contradicts

I’m not sure because I’m not sure I really understand it yet. One thing I see is that you have a dangling filter hanging out in the middle of another filter and you are trying to connect them using &&. && as one and only one meaning. It’s a boolean and operation. If the left side of the && evaluates to true and the right side evaluates to true than && returns true. In the above code you have a list on the left side gCalEvent.members.filter[ a | a.name == e.name+"_CalId".state.toString == mem.name.toString] and nonsense on right side of the && because you just have a call to filter not connected to any List. But even if you fixed that part (i.e. gCalEvent.members.filter[ act |) it still wouldn’t work because it too returns a List, not a boolean.

What I’m suggesting is to stop trying to do it all at once. For example:

// I am looking for all Ids that fulfill the requirement that the event summary contains the word flight.
val flightEvents = gCalEvent.members.filter[ act | act.name == e.name+"_Summary"].state.toString.split(" ").get(0) == "Flight" ]

// From this subset I am now taking the first one where the CalId is matching the current person I am looking for.
val memEvents = flightEvents.filter[ a | a.name == e.name+"_CallId".state.toString == mem.name ]
val event = memEvents.head

or using findFirst

// I am looking for all Ids that fulfill the requirement that the event summary contains the word flight.
val flightEvents = gCalEvent.members.filter[ act | act.name == e.name+"_Summary"].state.toString.split(" ").get(0) == "Flight" ]

// From this subset I am now taking the first one where the CalId is matching the current person I am looking for.
val event = flightEvents.findfirst[ a | a.name == e.name+"_CallId".state.toString == mem.name ]

Does this look like what you are after?

If so then, if desired, you can reassemble it into one line.

val event = gCalEvent.members.filter[ act | act.name == e.name+"_Summary"].state.toString.split(" ").get(0) == "Flight" ].findFirst[ a | a.name == e.name+"_CallId".state.toString == mem.name ]

Thank you. The help is very much appreciated.

I tried running your code. There is a [ ] mismatch which I think I fixed, although it still looks off to me. But the bigger problem is that “e” is not defined anywhere.

The part that makes all of this so headache inducing is that we are iterating through three different groups. The family members are easy but the other two are in my opinion the confusing ones. You seem to be leaving gCal out completely.

For just splitting the line into several parts the following works:

FamilyG.members.forEach[ mem | 	
//for each member of group FamilyG

	val eventflight = gCal.members.filter[ X | 
           gCalEvent.members.filter[ act |
	       act.name == X.name+"_Summary"].head.state.toString.split(" ").get(0) == "Flight"]
    //filter through gCal members (gCal_Event1, GCal_Event2...) to find a member that fullfills the conditions that
    //there is a member of gCalEvent (gCalEvent1_Summary, gCalEvent1_Location, ..., gCalEventX_Summary ..) that is called gCalEventX_Summary (where gCal and GCalEvent Event numbers are matching)
    //and its state contains "Flight"
            
    val event = eventflight.sortBy[name].findFirst[ X | 
    // sort by name	(and thereby event numbers which are chronological)
    // then take the first EventX(member of gCal)  
	gCalEvent.members.filter[ a | 
            a.name == X.name+"_CalId"].head.state.toString == mem.name.toString] 
   //for which there is a member of gCalEvent: gCalEventX_CalId which state is matching the current person (mem)

    logInfo("testrule", event)
]

My apologies about the indentation. It doesn’t seem to copy right.
I still don’t see a way to simplify from there. But I tried to comment more precisely, hopefully making the problem clearer.

And it still contains head. Which shouldn’t work (imo), but it does, which is confusing.
In our specific case there is currently a flight for me in Event3 and a flight for my husband in Event4

gCalEvent.members.filter[ a | 
            a.name == X.name+"_CalId"].head.state.toString == mem.name.toString] 
   //for which there is a member of gCalEventX_CalId which state is matching the current person (mem)

The way I understand it head should in both cases return Event3 here. Therefore the test should result in one flight for me during the first iteration through mem and none for my husband in the second. However the full code above correctly returns both Event3 and 4.

Obviously at this point this is less about finding a working example and more about getting a better understanding of why and how things work now. If the code becomes clearer through the process even better.

It has to contain head. There is no state method on a List. If you want to look at all the Items in the filter to get a list that match, you need another filter.

That’s why I still recommend separating these some more. It seems like you are trying to do a many to many comparison inside a filter and a filter simply cannot do that. If you want to do that sort of comparison, in set theory this would be called a union, you need to have two filters that follow each other. And maybe this is where the disconnect is because even with the comments I’m not sure I understand what you are trying to accomplish here.

Let’s break it down comment by comment, each line will show the part of the long expression that does what the comment says and I’ll gradually build it up. First I’ll implement what your comments say. Then I’ll implement what I think you mean.

    // filter through gCal members to find **a** member that fullfills the conditions
    val eventFlight = gCal.members.findFirst[ X | ] // you are looking for **a** member, so you want findFirst

    // There is a member of gCalEvent that is called gCalEventX_Summary where the X between X and this event match
    val eventFlight = gCal.members.findFirst[ X | 
        gCalEvent.members.findFirst[ act | act.name = X.name+"_Summary"]]

    // and its state contains "Flight"
    val eventFlight = gCal.members.findFirst[ X | 
        gCalEvent.members.findFirst[ act | act.name = X.name+"_Summary"] && 
        gCalEvent.members.findFirst[ act | act.state.toString.split(" ").get(0) == "Flight" ]

The above matches what your comments say. But the next line looks like what you really want are all the events that match the criteria. So let’s adjust it to do that.

    // filter through gCal members to find **all** members that fullfill the conditions
    val eventFlights = gCal.members.filter[ X | ] // you are looking for all members, so you want filter

    // There is a member of gCalEvent that is called gCalEventX_Summary where the X between X and this event match
    val eventFlights = gCal.members.filter[ X | 
        gCalEvent.members.findFirst[ act | act.name = X.name+"_Summary"]] // we still only care if there is one or more gCalEvent Item that matches the name so use findFirst

    // and its state contains "Flight"
    val eventFlights = gCal.members.filter[ X | 
        gCalEvent.members.findFirst[ act | act.name = X.name+"_Summary"] && 
        gCalEvent.members.findFirst[ act | act.state.toString.split(" ").get(0) == "Flight" ]

Moving on to the next line.

    // sort by name
    val event = eventFlights.sortBy[name]

    // then take the first EventX(member of gCal)
    val event = eventFlights.sortBy[name].findFirst[ X | ]

    // for which there is **a** member of gCalEvent:
    val event = eventFlights.sortBy[name].findFirst[ X |
         gCalEvent.members.findFirst[ a | ]] // we only care if there is one or more, use findFirst

    // gCalEventX_CalId
    val event = eventFlights.sortBy[name].findFirst[ X |
        gCalEvent.members.findFirst[ a |
            a.name == X.name+"_CalId"]]

    // whose state is matching the current person (mem)
    val event = eventFlights.sortBy[name].findFirst[ X |
        gCalEvent.members.findFirst[ a |
            a.name == X.name+"_CalId" 
            && a.state.toString == mem.name ]]

Putting it all together:

    // filter through gCal members to find **all** members that fullfill the conditions
    val eventFlights = gCal.members.filter[ X | 
        // There is a member of gCalEvent that is called gCalEventX_Summary where the X between X and this event match
        gCalEvent.members.findFirst[ act | act.name = X.name+"_Summary"] && 
        // and its state contains "Flight"
        gCalEvent.members.findFirst[ act | act.state.toString.split(" ").get(0) == "Flight" ]

    // sort by name then take the first EventX(member of gCal)
    val event = eventFlights.sortBy[name].findFirst[ X |
        // for which there is **a** member of gCalEvent:
        gCalEvent.members.findFirst[ a |
            // gCalEventX_CalId
            a.name == X.name+"_CalId" 
            // and whose state is matching the current person (mem)
            && a.state.toString == mem.name ]]

You probably have a mix of spaces and tabs. You should standardize on one or the other and the code should indent properly.

But Event3 has your name and Event4 has your husband’s name so when it loops on your mem, it returns Event3 and when it loops on your husband’s mem it returns Event4.

Note, if some of these events you are trying to findFirst on do not exist, this whole thing is going to error out. Unfortunately I can’t think of an easy way to avoid that without totally restructuring your Groups.

Yes, your second example is much closer to what I think it should be than the first based on my comments. I didn’t realize how misleading a simple “a” could be.

However:

Wouldn’t this also return an EventX where say “Flight” is in the first part of gCalEvent_Description?

// whose state is matching the current person (mem)
    val event = eventFlights.sortBy[name].findFirst[ X |
        gCalEvent.members.findFirst[ a |
            a.name == X.name+"_CalId" 
            && a.state.toString == mem.name ]]

and this one where the me.name is the state of any of the other Items?

Actually I think that’s right. We need to put both conditions in the one findFirst.

val eventFlights = gCal.members.filter[ X |
    gCalEvent.members.findFirst[ act | act.name == X.name+"_Summary" 
                                       && act.state.toString.startsWith("Flight") ]

Note I fixed the missing extra = in the first comparison.

So this code loops through all the members of gCal and returns a list of Items named gCal_EventX_Summary whose state starts with “Flight”. In order for the Item to be in the list both the name of the Item and the state need to match the given criteria.

Only the first Item whose name is gCal_EventX_CalId and it’s state matches mem.name is returned. Both conditions must match match to get an Item from the List.