With so many of us working from home, there's a new need to let others in our homes know when we're in meetings during the workday. To help with this around my home, I decided to make a simple "meeting indicator" device that shows when I'm busy/free during the workday by:
- Checking my Outlook calendar every 15 minutes to see if a meeting is happening
- Turning the LCD red if I'm in a meeting or green if I'm not
- Turning off the LCD screen outside of work hours
Here's how to make your own!
The meeting indicator is primarily a software project, but there are a few pieces of hardware that you'll need.
- Raspberry Pi ZeroI used the Pi Zero for the small form factor. The project will work with any Raspberry Pi, but you'll probably need to make some changes to the code if you use a model other than the Zero. More on that below.
- Adafruit i2c RGB Positive 16x2 LCD screen
The Raspberry Pi Zero uses the ARMv6 instruction set architecture. NodeJS officially supported the ARMv6 architecture until version 12. From version 12 onward, the NodeJS team has offered unofficial releases for ARMv6, but they generally aren't as well tested as official releases.
I decided to write the code for this application using Node 11 to use an officially supported version. Node version 11 didn't support the newer ES Module syntax, so the code for the project uses CommonJS. If you decide to use an unofficial version, you may need to explicitly update the code to use CommonJS syntax or ES Module syntax.
Clone the Github RepoThe code from my project is available in a Github repo. You can use this as a starting point and modify it for your needs.
For the app to make requests to Microsoft's APIs, it needs a few environment variables. The environment variables are configured using dotenv and are listed in the dev.env
file. dotenv will look for a .env
file, so you should do the following to copy the dev.env
file to a new .env
file:
cp dev.env .env
Now, you'll need to update the placeholders in the .env
file with the actual values for your app. Set up your app in Azure Active Directory as outlined below to get the necessary environment variables.
You need to register your application in Azure Active Directory (AAD) to manage permissions for accessing your Outlook calendar. AAD will also provide the IDs and secrets that you'll need to get Microsoft authentication tokens to use when requesting calendar data through the Microsoft Graph Explorer API.
If you don't want to connect directly to your work account (and use their Azure Portal), you can import your work calendar into a separate account that you can use for the meeting indicator. Microsoft offers a FREE developer program that you can use for this project.
Registering Your AppTo register your application in Azure, do the following:
1. Go to the Azure Portal and sign in to your account.
2. Search for Azure Active Directory in the services and click on it.
3. In the left navigation, click on "App registrations."
4. Create a new registration and complete the fields.
5. For the supported account types, select "accounts in this organizational directory only" since you're only going to connect to your account. Leave the redirect URI field empty because we're building a console application and won't need this (it's for web apps).
After registering your app, you'll be able to get the tenant and client IDs for the app's .env
file. Copy those values to the appropriate variables in the .env
file.
The app will need permissions to access your calendar.
1. On your app detail page, click "API Permissions" in the left navigation.
2. Add a permission and select Microsoft Graph.
3. Select application permissions.
4. Search and apply Calendars.Read
and Calendars.ReadWrite
.
5. Click the "Add permissions" button.
6. Click the "Grant admin consent" button on the main screen to give the application consent.
Creating a Client Secret for Your AppTo get the meetings from your calendar, you'll need a Microsoft authentication token to pass along with your API requests so that Microsoft can verify that the requests are coming from your authorized application. You need a client secret to send with the token request to get an authentication token.
1. Click "Certificates & secrets" on your app detail page in the left navigation.
2. Add a new client secret.
- Note: when the expiration date for the secret expires, your meeting indicator will stop working until you create a new secret and update the client secret value in the code. A shorter time period is more secure, though.
3. After you've created the secret, copy the value (not the secret ID) for the new client secret and paste it into your .env
file as the CLIENT_SECRET
value.
The last Azure Active Directory environment variable that you'll need to update is the OUTLOOK_CALENDAR_ID
. This variable specifies which calendar you want the meeting indicator to check. To get the calendar ID:
1. Log into the Microsoft Graph Explorer.
2. Search for sample calendar queries.
3. Run the "get all my calendars" request.
4. Find the calendar you want to use and put its ID into the .env
file as the OUTLOOK_CALENDAR_ID
valu
Now that the configuration is out of the way let's move on to the more fun stuff!
Configuring the HardwareAttach the LCD screen to the Raspberry Pi general-purpose input-output (GPIO) pins so that the female connections on the LCD screen fully line up with the male pins on the Raspberry Pi. That's all you need to do to configure the hardware.
The CodeAccessing the LCD ScreenThe app uses the adafruit-i2c-LCD
package to access the LCD screen.
My LCD screen runs on i2c bus one at the address 0x20
. Yours might be at a different address. If the app can't access the LCD screen, use the i2c-tools
package to find the address for your screen and update the line below accordingly.
// Creates a reference to the LCD screen based on its i2c address
const lcd = new LCDPLATE(1, 0x20)
Running sudo i2cdetect -y 1
to find the address for your LCD screen should show something like this:
For more information on interacting with the LCD screen, check out the documentation for the adafruit-i2c-LCD
package.
Since Outlook provides the event dates and times in UTC, using UTC was the easiest way to handle setting dates and times for comparison purposes, so all dates and times in the app use UTC.
// A date object representing the current date and time in UTC
const currentDateTime = DateTime.utc()
The app uses the luxon
package to work with date and time values more easily. It provides many helper functions for converting dates, formatting dates, and checking information about a date (like the day of the week). The weekday
property below is from luxon
and is used to turn off the backlight on the LCD screen on weekends.
// Numeric value representing day of week; 6 is Saturday and 7 is Sunday
const { weekday } = currentDateTime
if (weekday === 6 || weekday === 7) {
turnOffScreen()
return
}
Running the ApplicationThe start
function is the entry point to the meeting indicator app. It's called once to start the app.
The node-cron
package calls the start
function every 15 minutes after the initial running of the app to check for meetings and update the LCD screen at 15-minute intervals. Depending on your needs, you can change this value to check more or less often.
cron.schedule('*/15 * * * *',, start) // runs once every 15 minutes
start()
The start
function does the following:
- Checks to see if the current time is outside work hours and turns off the screen if it is
- Checks to see if an event is happening now
- Displays "Meeting in Progress!" message with a red backlight if there's an event happening now
- Displays "I'm free!" message with a green backlight if there isn't an event happening now
It also catches errors. There are many ways to handle errors in an application. I'm using the Pushover service to send push notifications to my phone when errors occur in the app so that I'm aware of them. If you want to use Pushover, be sure to set the PUSHOVER_TOKEN
and PUSHOVER_USER
environment variables in the .env
file based on your Pushover account setup. Otherwise, you can remove the Pushover fetch
request from the catch
block within the start
function.
const start = async () => {
try {
const currentDateTime = DateTime.utc()
// Numeric value representing day of week; 6 is Saturday and 7 is Sunday
const { weekday } = currentDateTime
if (weekday === 6 || weekday === 7) {
turnOffScreen()
return
}
// A date string representing the work start time (9 am) for the current date in UTC
const workStart = DateTime.utc(
currentDateTime.year,
currentDateTime.month,
currentDateTime.day,
14,
0,
0
).toString()
// A date string representing the work end time (5 pm) for the current date in UTC
const workEnd = DateTime.utc(
currentDateTime.year,
currentDateTime.month,
currentDateTime.day,
22,
0,
0
).toString()
// Turn off the screen outside of work hours
if (
Date.parse(workStart) > Date.parse(currentDateTime.toString()) ||
Date.parse(currentDateTime.toString()) > Date.parse(workEnd)
) {
turnOffScreen()
return
}
const meetings = await fetchMeetings()
const isHappeningNow = isMeetingHappeningNow(meetings)
// Clear the previous message from the screen
lcd.clear()
// If there's a meeting happening, show a message and make the screen red
if (isHappeningNow) {
lcd.backlight(lcd.colors.RED)
lcd.message('Meeting in\nProgress!')
} else {
// If there isn't a meeting happening, show a message and make the screen green
lcd.backlight(lcd.colors.GREEN)
lcd.message("I'm free!")
}
} catch (error) {
console.log(error)
await fetch(
`https://api.pushover.net/1/messages.json?title=Meeting+Indicator+Error&message=${error.message}&token=${process.env.PUSHOVER_TOKEN}&user=${process.env.PUSHOVER_USER}`,
{ method: 'POST' }
)
}
}
cron.schedule('*/15 * * * *', start) // runs once every 15 minutes
start()
Getting the MeetingsBefore getting the meetings from Outlook using the Microsoft Graph Explorer API, we need to get an authentication token to send user data and meeting requests. The app uses the @azure/msal-node
package to authenticate with Microsoft.
The authentication flow is configured as follows:
The authentication code is in the auth.js
file (including the getToken
function below), copied from Microsoft's example Node console app with minor modifications.
// Gets the Microsoft Graph Explorer API auth token
const getAuthToken = async () => {
const authResponse = await getToken(tokenRequest)
return authResponse.accessToken
}
After getting the access token, we get the current date and time. We also calculate the date and time 30 minutes in the future. These will be used as query parameters in the API request to provide the timeframe we want to use to retrieve meetings.
const currentDateTime = DateTime.utc().toString()
const thirtyMinutesFromNow = DateTime.utc().plus({ minutes: 30 }).toString()
We still need to provide one more piece of data that we don't have yet: a user ID. The user ID identifies whose calendar to search for meetings. We need to call the Graph Explorer API users endpoint to get the user ID.
When requesting the user data, the authentication token must be included as a bearer token in the Authorization
request headers. The apiOptions
parameter in the function below contains the correct headers.
// Get's my user ID from Microsoft Graph Explorer API
const getUserId = async (apiOptions) => {
const userRes = await fetch(apiConfig.uri, apiOptions)
const { value } = await userRes.json()
return value[0].id
}
Here’s a sample request and response:
// Request:
// https://graph.microsoft.com/v1.0/users/USER_ID/calendars/CALENDAR_ID/calendarview?startdatetime=2022-01-14T14:00:00.0000000&enddatetime=2022-01-14T15:30:00.0000000
// Sample Response:
{
"@odata.context": "<https://graph.microsoft.com/v1.0/$metadata#users('USER_ID')/calendars('CALENDAR_ID')/calendarView>",
"value": [
{
"@odata.etag": "",
"id": "EVENT_ID",
"createdDateTime": "2022-01-04T01:46:27.7022336Z",
"lastModifiedDateTime": "2022-01-10T18:15:46.6799499Z",
"changeKey": "",
"categories": [],
"transactionId": null,
"originalStartTimeZone": "Eastern Standard Time",
"originalEndTimeZone": "Eastern Standard Time",
"iCalUId": "",
"reminderMinutesBeforeStart": 15,
"isReminderOn": false,
"hasAttachments": false,
"subject": "Busy",
"bodyPreview": "",
"importance": "normal",
"sensitivity": "normal",
"isAllDay": false,
"isCancelled": false,
"isOrganizer": true,
"responseRequested": true,
"seriesMasterId": null,
"showAs": "busy",
"type": "singleInstance",
"webLink": LINK TO OUTLOOK EVENT,
"onlineMeetingUrl": null,
"isOnlineMeeting": false,
"onlineMeetingProvider": "unknown",
"allowNewTimeProposals": true,
"isDraft": false,
"hideAttendees": false,
"recurrence": null,
"onlineMeeting": null,
"responseStatus": {
"response": "organizer",
"time": "0001-01-01T00:00:00Z"
},
"body": {
"contentType": "html",
"content": "<html><head><meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=utf-8\\"><meta name=\\"Generator\\" content=\\"Microsoft Exchange Server\\"><!-- converted from rtf --><style><!-- .EmailQuote { margin-left: 1pt; padding-left: 4pt; border-left: #800000 2px solid; } --></style></head><body><font face=\\"Times New Roman\\" size=\\"3\\"><span style=\\"font-size:12pt;\\"><a name=\\"BM_BEGIN\\"></a></span></font></body></html>"
},
"start": {
"dateTime": "2022-01-14T14:00:00.0000000",
"timeZone": "UTC"
},
"end": {
"dateTime": "2022-01-14T17:00:00.0000000",
"timeZone": "UTC"
},
"location": {
"displayName": "",
"locationType": "default",
"uniqueIdType": "unknown",
"address": {},
"coordinates": {}
},
"locations": [],
"attendees": [],
"organizer": {
"emailAddress": {
"name": YOUR_NAME,
"address": EMAIL_ADDRESS
}}}
]
}
Once we have the access token, start and end dates, and a user ID, we can send a request to the Graph Explorer API to get the Outlook meetings.
// Gets the calendar events from Outlook using the Microsoft Graph Explorer API
const fetchMeetings = async () => {
const accessToken = await getAuthToken()
const currentDateTime = DateTime.utc().toString()
const thirtyMinutesFromNow = DateTime.utc().plus({ minutes: 30 }).toString()
const options = {
headers: {
Authorization: `Bearer ${accessToken}`
}
}
const userId = await getUserId(options)
// The OUTLOOK_CALENDAR_ID specifies which of your calendars to get meetings from
const endpointWithQueryParams = `https://graph.microsoft.com/v1.0/users/${userId}/calendars/${process.env.OUTLOOK_CALENDAR_ID}/calendarview?startdatetime=${currentDateTime}&enddatetime=${thirtyMinutesFromNow}`
const meetingRes = await fetch(endpointWithQueryParams, options)
const { value: meetings } = await meetingRes.json()
return meetings
}
You should now have a working meeting indicator!
Troubleshooting TipsIf the current date or time used in the script seems incorrect, verify that the time is set correctly on the Raspberry Pi. You can check this by running the timedatectl
command. You can also use timedatectl
to intentionally change the time on the Pi to test aspects of the app.
You can use pm2
to run the application whenever your Raspberry Pi starts. For information on configuring pm2 on your Pi, check out my blog post about automatically starting NodeJs applications.
Icon Credits
The icons used in the authentication diagram are from Flaticon: application, automated-process, and server.
Comments