I first got the idea during covid lockdown. The world was slowly getting used to remote work, appreciating its benefits and navigating the challenges it brought as the line separating personal and professional worlds got increasingly blurry.

A mild inconvenience I often faced was that I would be on a call and my parents would call me for lunch or dinner — not knowing I was busy. I bought a smart light and put it in a place where it could be easily seen. Before a meeting started I would turn it on from the mobile app and give it a red color implying that I was busy. I saw this practice in hospitals where a red light above the doctor’s door indicated they were seeing a patient. So it wasn’t a novel idea by any means.

Last year in 2024, when I was applying for jobs I got a take-home assignment from a company in which I had to build something using Gmail APIs. Things didn’t work out in the end, but the exercise gave me decent familiarity with Google APIs and its cloud console.

And somewhere around the same time, I came across this open-source library called openhue-go while scrolling reddit.

These individual events or inputs marinated in my mind for sometime and eventually, something clicked. I realised I had all the tools I needed. A perfect use case, experience working with Google APIs, and an open source library to control lights. I just had to tie them together.

I wanted the bulb to light up automatically whenever there was an event on my google calendar and turn off when the event ended — without requiring any human involvement.

Here’s a sneak peak into the final result.

 

At a very high level there are 3 logical pieces involved.

  • A calendar app whose purpose would be to notify on event start and end.
  • A bulb handler that will act on the notifications.
  • The bulb itself.

Coding the calendar app

I wrote this in Go and named it gcal-notify. The logic is simple. I fetch all events for the current day and merge the ones that overlap. I merge because I want the light to stay “on” for as long as there is an ongoing event on my calendar. By merging overlapping events I get the final time boundaries — between which the bulb must stay “on”.

Diagram showing how overlapping calendar events are merged

It reduces to an interval merging problem. We store the events in a list, sort them by their start times, iterate the list and merge two events if they overlap.

Once I have my list of events for the day, I find out the start time of the upcoming event and save it locally. Whenever the current time equals the upcoming event’s start time, I invoke the logic that lights up the bulb.

UpcomingEvent is an important logical construct in this application. It refers to an event that’s currently in progress, if one exists, or the next event relative to current time.

To achieve this, I run a ticker that ticks every second, and in each tick I check if the upcoming event has started. If the current time is within the bounds of the upcoming event’s start and end times, it implies that the event is in progress, and I call a function to turn on the bulb. Likewise, when the current time is past the upcoming event’s end time — implying that the event has ended — I call the appropriate function to turn it off. Lastly and most importantly, I set the next upcoming event.

Here’s the code. I have skipped few portions that handle a couple of edge-cases to keep it short and convey the crux of the logic. If you’re interested to know more, see the source.

func (n *Notifier) watch() {
	for {
		select {
		case <-n.done:
			return

		case <-n.t.C:
			if n.UpcomingEvent.inProgress() && n.StartEventThrottler.Allow() {
				// Light it up!
				n.notifyHueAgent(eventStarted)
				continue
			}

			if n.UpcomingEvent.hasEnded() {
				// You think darkness is your ally
				n.notifyHueAgent(eventEnded)
				// Don't forget to calculate the next upcoming event
				n.setUpcomingEvent()
			}
		}
	}
}

I have put a throttler n.StartEventThrottler.Allow() in place. It’s for rate limiting requests to the bulb controller. Since the ticker runs every second, I definitely don’t want to bombard the light with repeated “turn-on”/“turn-off” requests. At the same time, some spaced repetition is needed because, its possible that the first request might not reach the bulb. It’s a network call after all. Secondly, I need to consider the possibility of intermittent power cuts. Whenever the power comes back, I want the light to act as per the intended logic.

n.notifyHueAgent is the function that instructs the bulb handler to turn on/off the bulb.

Figuring out next upcoming event is straightforward. The events (n.MergedEvents) are already stored, so we just return the next event on the calendar relative to current time.

func (n *Notifier) setUpcomingEvent() {
	n.UpcomingEvent = nil

	for _, mergedEvent := range n.MergedEvents {
		now := time.Now()

		// start >= now <= end
		onGoingEvent := (now.After(mergedEvent.StartTime) || now.Equal(mergedEvent.StartTime)) &&
			(now.Before(mergedEvent.EndTime) || now.Equal(mergedEvent.EndTime))

		upComingEvent := now.Before(mergedEvent.StartTime)

		if onGoingEvent || upComingEvent {
			n.UpcomingEvent = mergedEvent
			break
		}
	}
}

Recall that UpcomingEvent is the in-progress event — if there exists one, or else the next immediate event that’s coming up. That’s why, I have that onGoingEvent check there.

A new day comes with new events

Since I fetch events only for the current day.

now := time.Now()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local).Format(time.RFC3339)
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, time.Local).Format(time.RFC3339)

events, err := n.Service.Events.List(n.calendarId).TimeMin(startOfDay).TimeMax(endOfDay).Do()
if err != nil {
	// handle error
}

There is an obvious need to refresh the events list post midnight. It seemed reasonable to place this new-day-detection logic inside the ticker.

func (n *Notifier) watch() {
	for {
		select {
		case <-n.done:
			return

		case <-n.t.C:

			if n.currentDay != time.Now().Day() {
				log.Println("syncing calendar post midnight")

				n.currentDay = time.Now().Day()

				err := n.syncCalendar()
				if err != nil {
					log.Println("error syncing calendar post midnight")
					continue
				}
			}

			// code for turning bulb on/off on event start/end.
		}
	}
}

Handling calendar updates

So far, I didn’t talk about how to handle calendar updates. A calendar gets updated when a new event is added, or an old one is deleted or modified. Everytime that happens, I need to repeat the steps I did above — fetch all events, sort them, merge the overlapping ones, set the upcoming event and save its start time.

To get notified on updates, Google provides a Watch API. The idea is to register a watcher to watch over a resource you care about — in my case, it’s events. The watcher notifies you when anything about that resource changes. This watch API is common for all watchable Calendar API resources.

Notifications are delivered through notification channels which must be set up individually for each resource type you want to monitor. It is setup by invoking the Watch API.

ch, err := notifier.Service.Events.Watch(os.Getenv(calendarId), &calendar.Channel{
	Id:      uuid.New().String(),
	Address: fmt.Sprintf("%s/notify", os.Getenv(notificationChannelEndpoint)),
	Type:    channelTypeWebhook,
}).Do()

The API takes a calendar.Channel instance. The Address field of the channel is set to the webhook callback url where notifications are delivered. It must be an HTTPS address with a valid SSL certificate — otherwise, Google Calendar API won’t be able to send notifications. Invalid certificates include

  • Self-signed certificates
  • Certificates signed by untrusted source.
  • Revoked certificates.
  • Certificates that have a subject that does not match with target hostname.

Notification channels come with expiry. As of now, the calendar API does not support automatic renewal of channels. When a channel is close to its expiry, we have to replace it with a new one by calling the Watch API again.

It’s also important to clarify that the callback request doesn’t give any details about the nature of update. As in, we don’t get to know what was updated or how. We are just told that something has changed — which I feel is a intended design choice. It keeps things simple. Anyway, here’s my handler that recalibrates everything on update.

func NewRequestMultiplexer(notifier *Notifier) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/notify", notifier.handleCalendarUpdates)
	return mux
}

func (n *Notifier) handleCalendarUpdates(w http.ResponseWriter, r *http.Request) {
	// validation logic
	err := n.syncCalendar()
	if err != nil {
		// log error, do nothing else
	}
	w.WriteHeader(http.StatusOK)
}

Authorization using service account

I had recently rented a digital ocean droplet for hosting my hobby projects. Each project runs in its own dedicated container with a reverse proxy sitting at front — also inside a container. I wanted this calendar app to run there too because, I wanted this system to work 24/7, like a background process.

I had previously configured the authorization to use the OAuth flow where the user is shown a consent screen and is asked to grant permissions to the app. This step required manual intervention. With the app running inside a VPS, there was no scope for that. The right choice for this kind of scenario is to use a service account.

A service account is essentially an identity. When you create a service account, you are creating an account of a user impersonating you. Just that this user is not a human. Yet it will have its own google calendar, own “primary” calendar — this bit is important, I will explain later.

To authenticate with a service account, there are two mandatory steps that needs to be done.

  • First, I have to share my calendar with this service account user. This can be done from the calendar settings. How to share your google calendar with a service account

     

  • Secondly, my primary calendar — where I create events, must be included in the calendar list of the service account user. The code below ensures exactly that. I first check if my primary calendar exists in the list — if not, I insert it.

     

    This is a one-time-only action. I do this on application startup.

     

    func assertUserCalendarExistsInSvcAccount(notifier *Notifier) {
    
    	calendarList, err := notifier.Service.CalendarList.List().Do()
    	if err != nil {
    		// handle error
    	}
    
    	// ensure calendar with my calendar id exists
    	idx := slices.IndexFunc(calendarList.Items, func(entry *calendar.CalendarListEntry) bool {
    		return entry.Id == notifier.calendarId
    	})
    
    	if idx < 0 {
    		entry := &calendar.CalendarListEntry{
    			Id: notifier.calendarId,
    		}
    
    		entry, err := notifier.Service.CalendarList.Insert(entry).Do()
    		if err != nil {
    			// handle error
    		}
    	}
    }
    

     

These two are nitty gritty, yet mandatory steps — without which my calendar app won’t work as intended.

Since I authenticated using service account, the app will only know the service account user. It won’t know me. All future API requests will be made on behalf of this user. So when I fetched calendar list in the above code, it is fetching the calendar list of the service account user.

It’s essential to understand that, even though the service account user is mimicking my identity, it’s still a different user. It won’t have access to my primary calendar unless I grant it access. If it can’t see my calendar, it obviously can’t see my events. If it can’t see my events, it won’t know when to turn the bulb on or off.

I feel these distinctions are not widely talked about — or atleast I couldn’t find them when I was struggling to get it working. So I hope this was helpful.

“primary” calendar is a matter of perspective

Primary calendar is the default calendar associated with your Google account — where events are created by default and where invitations are sent. Since the service account user and I are different users, our primary calendars are going to be different.

So in these two portions of the above snippet,

idx := slices.IndexFunc(calendarList.Items, func(entry *calendar.CalendarListEntry) bool {
	return entry.Id == notifier.calendarId
})
entry := &calendar.CalendarListEntry{
	Id: notifier.calendarId,
}

If I had set notifier.calendarId to "primary", I won’t get the intended outcome. The right value for calendarId in this context is my email id.

"primary" works fine if you’re not using a service account. But if you are, the meaning of "primary" becomes obscure. The program needs to know specifically whose primary calendar I am refering to — mine or the service account user’s. Using email id removes that ambiguity.

A natural takeaway from all of that is, a calendar owner’s email id can be used interchangeably with "primary" when making API calls on their behalf.

From logs to lightbulbs

Once I got the setup working on my VPS, I went ahead and purchased the lights. Up until that point, I was just printing messages on stdout — “light it up!”, and “put that thing off”. I wasn’t willing to spend money unless things looked promising.

I bought the white-ambiance bulb since it was cheaper among the other options, and it served my usecase perfectly. I also had to purchase a hue bridge, as it’s needed to control these bulbs. Once the bridge is setup, it gets its own IP address.

Whether I control the lights programmatically or from the mobile app, all communication goes through the bridge. My app talks to the bridge over the network, and the bridge, in turn, communicates with the bulbs using Zigbee protocol.

This purchase cost me INR 6300 (bulb: 1800, bridge: 4500).

The case for Tailscale

I got the light and the bridge delivered next day and as I was unpacking it, a realization started to dawn on me. My calendar app is running in my VPS. The bridge will be working within my home network. These are two different networks, so unless I expose the bridge’s IP to the public internet - which is a very bad idea, there was no way I can make this communication happen.

I found myself stuck for a while and spent this time looking for advice/suggestions on the internet. Reddit was particularly helpful here, especially subreddits such as r/selfhosted, and r/homelab. There were multiple suggestions, but one particular approach seemed like a common choice among many.

The suggestions didn’t make complete sense right away, so I spent a night or more letting it all soak in. My attempts to give meaning to differing suggestions made it time taking. I took a step back and tried to reason through the problem from first principles.

What’s known is that, there are two different networks involved — one containing the VPS, and the other, my home network, has the hue bridge. This part was not going to change. Hence, some REST API call must be involved — so that the “turn on”, “turn off” communication can take place.

  • If I take the REST API approach to communicate, there has to be a server running on the same network as the bridge. So where would it run? Another computer? A Raspberry Pi? That seems more practical because, my laptop won’t be on 24/7.
  • But, even with a Pi, I would still have the same security concern that I had with the bridge — I can’t expose its IP to the public internet. How does a Pi help me then?
  • Can I mask the Pi’s IP address? Sounds like a VPN thingy. Do I need a VPN?
  • This can’t be a new problem I’m tackling, what’s the go-to solution in the nerdverse?

This internal dialogue led me to discovering Tailscale. It’s a fascinating piece of technology and I highly respect its creators. Their official blog probably does the best job explaining it, but if I have to explain from my limited understanding, tailscale makes it possible for computers to share files, communicate with each another as if they were connected over LAN — even if these computers are geographically apart. It creates a private mesh VPN network called a tailnet, and assigns virtual IP addresses to all devices on that network.

All I needed to do was to install tailscale on my VPS and my Pi, sign in to my tailscale account from both these devices to get them added to my tailnet. That was it. It was done! My VPS and Pi could now freely talk to each other, without any concern of exposing IP addresses. And it works. It just works!

And to answer my own question,

  • But, even with a Pi, I would still have the same security concern that I had with the bridge — I can’t expose its IP to the public internet. How does a Pi help me then?

It helps because, you can install Tailscale on your Pi, but not on your hue bridge.

Pi in the cart

So I bought myself a Raspberry Pi 4 Model B. This cost me about INR 8000 — including few extra things like the pi case, a memory card, HDMI cable and the power adapter. I find it mildly annoying that the power adapter doesn’t come with the Pi and has to be bought separately.

Anyway, the case had a design flaw. The Pi won’t fit in it properly with the SD card inserted in it. And if I try to insert the the card later — i.e, after putting the Pi inside the case, it won’t go in either, as the case blocks the card slot. So, I chiseled the opening around the slot a bit so that I could slide it in.

Scraping the Raspberry Pi case with a chisel around the SD card slot

 

The agent who runs on Pi

I had to run a web server on this Pi that will respond to the http requests from the calendar app, and control the lights accordingly. I created a small repo called hue-agent that will run this server and expose an API /light/state for the calendar app to communicate.

I defined a light state request type.

type LightStateRequest struct {
	On         bool    `json:"on,omitempty"`
	Mirek      int     `json:"mirek,omitempty"`
	Brightness float32 `json:"brightness,omitempty"`
}

Here Mirek or mired is a unit for color temperature. A lower value produces cooler, bluish light, while a higher value produces warmer, yellowish light.

And this is the API and its handler

func NewRequestMultiplexer(agent *HueAgent) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/light/state", validationMiddleware(agent.updateBulbState))
	return mux
}

func (agent *HueAgent) updateBulbState(w http.ResponseWriter, r *http.Request) {
	// unmarshalling and validation done in validation middleware

	body, ok := r.Context().Value(lightStatePayloadKey).(LightStateRequest)
	if !ok {
		// handle error and return
	}


	var myLight openhue.LightGet
	lightsMap, err := agent.home.GetLights()
	if err != nil {
		// handle error and return
	}

	for k, v := range lightsMap {
		if k == os.Getenv(lightId) {
			myLight = v
		}
	}

	if err := agent.home.UpdateLight(*myLight.Id, openhue.LightPut{
		On: &openhue.On{On: &body.On},
		ColorTemperature: &openhue.ColorTemperature{
			Mirek: &body.Mirek,
		},
		Dimming: &openhue.Dimming{Brightness: &body.Brightness},
	}); err != nil {
		// handle error and return
	}

	// return success response HTTP 200
}

Looking back

This was a genuinely fun project. I picked up some devopsy skills simply because I chose to host it on a VPS — instead of abandoing it after few successful local runs — which I have often done with my previous projects. And that choice made all the difference. It taught me things I wouldn’t have learned otherwise. It pushed me to go deep into some details that I usually don’t tinker with in day-to-day work. If that’s not all, the process has left me with new ideas around how to improve my existing build system, with new tools.

I’m excited because this setup can be used in a lot of interesting ways. The scope for being creative is wide. For example, this can act as a noise-less, yet very effective morning alarm. Although my wife didn’t seem too excited about this idea.

The first time I saw the bulb light up when an event started, it was a moment of pure joy! I watched the logs on my VPS terminal, and the logs from hue-agent on my Pi, and I could mentally trace the entire path the request travelled through — to eventually light up the bulb. It was my mini eureka moment.

Here’s a diagram that shows the architectural as well as some of the logical components of the calendar app.

A diagram showcasing the logical and architectural components

Before you go

I want to say that I tend to use em dashes(—) a lot when I write. I think it looks neat and offers the right amount of pause without breaking the flow. I say this because there is a growing assumption online that anything containing em dashes is written using ChatGPT.

Having said that, English isn’t my first language and there are times when I struggle to convery my thoughts and explanations in simpler words. This commonly happens when I’m explaining something technical, and going deep into the finer details. In those cases, I take help. Because I don’t want to overwhelm the reader.

For anyone who’s curious, you can type em dash on mac using option + shift + -

References