Using Org Mode, Emacs Lisp, and TRAMP to parse meetup calendar entries and generate a crontab
Posted: - Modified: | org, emacs- : Tweaked script to report when the server is already downscaled
- : Add a check to see if the meetings are still running
Times and time zones trip me up. Even with calendar notifications, I still fumble scheduled events. Automation helps me avoid embarrassing hiccups.
We run BigBlueButton as a self-hosted web conferencing server for EmacsConf. It needs at least 8 GB of RAM when active. When it's dormant, it fits on a 1 GB RAM virtual private server. It's easy enough to scale the server up and down as needed. Using the server for Emacs meetups in between EmacsConfs gives people a way to get together, and it also means I can regularly test the infrastructure. That makes scaling it up for EmacsConf less nerve-wracking.
I have some code that processes various Emacs
meetup iCalendar files (often with repeating
entries) and combines them into one iCal file that
people can subscribe to calendar, as well as Org files in different timezones that they can include
in their org-agenda-files. The code I use to
parse the iCal seems to handle time zones and
daylight savings time just fine. I set it up so
that the Org files have simple non-repeating
entries, which makes them easy to parse. I can use
the Org file to determine the scheduled jobs to
run with cron on a home server (named xu4) that's
up all the time.
This code parses the Org file for schedule information, then generates pairs of crontab entries. The first entry scales the BigBlueButton server up 1 hour before the event using my bbb-testing script, and the second entry scales the server down 6 hours after the event using my bbb-dormant script (more info). That gives organizers time to test it before the event starts, and it gives people plenty of time to chat. A shared CPU 8 GB RAM Linode costs USD 0.072 per hour, so that's USD 0.50 per meetup hosted.
Using #+begin_src emacs-lisp :file "/ssh:xu4:~/bbb.crontab" :results file as the header for my code block and using an
SSH agent for authentication lets me use TRAMP to
write the file directly to the server. (See
Results of Evaluation (The Org Manual))
(let* ((file "/home/sacha/sync/emacs-calendar/emacs-calendar-toronto.org")
(time-format "%M %H %d %m")
(bbb-meetups "OrgMeetup\\|Emacs-Berlin Hybrid\\|Emacs APAC")
(scale-up "/home/sacha/bin/bbb-testing")
(scale-down "/home/sacha/bin/bbb-dormant")
(maybe-scale-down "/home/sacha/bin/bbb-maybe-dormant")
(extra "/home/sacha/sync/emacs-calendar/extra.txt"))
(concat
(mapconcat
(lambda (o)
(let* ((base-time (car o))
(name (cdr o))
(end-time (+ (car o) (* 6 3600))))
(concat
(format "# %s\n" name)
;; Start
(format "%s * %s\n"
(format-time-string time-format (- base-time 3600))
scale-up)
;; In between
(cl-loop
for time
from (+ base-time (* 2 3600)) ; 2 hours after start
to (- end-time 1800) ; half hour before 6 hours
by 1800 ; every half-hour
concat
(format "%s * %s\n"
(format-time-string time-format time)
maybe-scale-down))
;; End
(format "%s * %s\n"
(format-time-string time-format end-time)
scale-down))))
(delq nil
(with-temp-buffer
(insert-file-contents file)
(org-mode)
(goto-char (point-min))
(org-map-entries
(lambda ()
(when (and
(string-match bbb-meetups (org-entry-get (point) "ITEM"))
(re-search-forward org-tr-regexp (save-excursion (org-end-of-subtree)) t))
(let ((time (match-string 0)))
(cons (org-time-string-to-seconds time)
(format "%s - %s" (org-entry-get (point) "ITEM") time)))))
"LEVEL=1")))
"\n")
"\n"
(if (file-exists-p extra)
(with-temp-buffer
(insert-file-contents extra)
(buffer-string))
"")))
The code makes entries that look like this:
# OrgMeetup (virtual) - <2026-01-14 Wed 11:00>--<2026-01-14 Wed 21:00> 00 10 14 01 * /home/sacha/bin/bbb-testing 00 13 14 01 * /home/sacha/bin/bbb-maybe-dormant 30 13 14 01 * /home/sacha/bin/bbb-maybe-dormant 00 14 14 01 * /home/sacha/bin/bbb-maybe-dormant 30 14 14 01 * /home/sacha/bin/bbb-maybe-dormant 00 15 14 01 * /home/sacha/bin/bbb-maybe-dormant 30 15 14 01 * /home/sacha/bin/bbb-maybe-dormant 00 16 14 01 * /home/sacha/bin/bbb-maybe-dormant 30 16 14 01 * /home/sacha/bin/bbb-maybe-dormant 00 17 14 01 * /home/sacha/bin/bbb-dormant # Emacs Berlin (hybrid, in English) - <2026-01-28 Wed 12:30>--<2026-01-28 Wed 20:30> 30 11 28 01 * /home/sacha/bin/bbb-testing 30 14 28 01 * /home/sacha/bin/bbb-maybe-dormant 00 15 28 01 * /home/sacha/bin/bbb-maybe-dormant 30 15 28 01 * /home/sacha/bin/bbb-maybe-dormant 00 16 28 01 * /home/sacha/bin/bbb-maybe-dormant 30 16 28 01 * /home/sacha/bin/bbb-maybe-dormant 00 17 28 01 * /home/sacha/bin/bbb-maybe-dormant 30 17 28 01 * /home/sacha/bin/bbb-maybe-dormant 00 18 28 01 * /home/sacha/bin/bbb-maybe-dormant 30 18 28 01 * /home/sacha/bin/bbb-dormant
bbb-maybe-dormant is:
#!/bin/bash
source ~/.profile
CHECK_SCRIPT="/home/sacha/bin/check_meetings.py"
SCRIPT_OUTPUT=$(python3 $CHECK_SCRIPT)
ACTIVE_COUNT=$(echo "$SCRIPT_OUTPUT" | grep -c "Active:")
if echo "$SCRIPT_OUTPUT" | grep -q "Already downsized"; then
echo $(date) "Node already downsized, no action needed." | tee -a /home/sacha/bbb.log
elif [ "$ACTIVE_COUNT" -eq 0 ]; then
echo $(date) "No active meetings found. Okay to put it to sleep..." | tee -a /home/sacha/bbb.log
~/bin/bbb-dormant
else
echo $(date) "Server is busy with $ACTIVE_COUNT active meeting(s)." | tee -a /home/sacha/bbb.log
fi
and that uses this Python file to query the API:
Python script to check if there are ongoing meetings
import hashlib
import requests
import os
from dotenv import load_dotenv
load_dotenv()
SECRET = os.environ['BBB_SECRET']
URL = os.environ['BBB_API_URL']
if SECRET is None or URL is None:
print("Please specify BBB_SECRET and BBB_API_URL")
exit(1)
import xml.etree.ElementTree as ET
def format_report(text):
root = ET.fromstring(text)
s = ""
active_meetings = [
m.find('meetingName').text + ' (' + m.find('participantCount').text + ')'
for m in root.findall('.//meeting')
if m.find('running').text == 'true'
]
if active_meetings:
for name in active_meetings:
s += f"Active: {name}\n"
else:
s = "None"
return s
def get_meetings():
query = "getMeetings"
checksum = hashlib.sha1((query + SECRET).encode('utf-8')).hexdigest()
full_url = f"{URL}{query}?checksum={checksum}"
response = requests.get(full_url)
if 'Bad Gateway' in response.text:
print('Already downsized')
else:
print(format_report(response.text).strip())
get_meetings()
Here's bbb-dormant:
#!/bin/bash
source /home/sacha/.profile
PATH=/home/sacha/.local/bin/:$PATH
CURRENT_TYPE=$(linode-cli linodes view $BBB_ID --format "type" --text --no-headers)
echo "Current node type: $CURRENT_TYPE"
if [ "$CURRENT_TYPE" != "g6-nanode-1" ]; then
echo Powering off
linode-cli linodes shutdown $BBB_ID
# Wait for the Linode to reach the 'offline' state before resizing
sleep 60
echo "Waiting for shutdown to complete..."
while [ "$(linode-cli linodes view $BBB_ID --format 'status' --text --no-headers)" != "offline" ]; do
sleep 5
done
echo $(date) "Resizing BBB node to nanode, dormant" | tee -a /home/sacha/bbb.log
linode-cli linodes resize $BBB_ID --type g6-nanode-1 --allow_auto_disk_resize false
sleep 300
linode-cli linodes boot $BBB_ID
fi
The code for generating the crontab works because meetups don't currently overlap. If there were, I'll need to tweak the code so that the server isn't downscaled in the middle of a meetup. It'll be a good problem to have.
I need to load the crontab entries by using
crontab bbb.crontab. Again, I can tell Org Mode
to run this on the xu4 home server. This time I
use the :dir argument to specify the default
directory, like this:
#+begin_src sh :dir "/ssh:xu4:~" :results silent
crontab bbb.crontab
#+end_src
Then cron can take care of things automatically, and I'll just get the e-mail notifications from Linode telling me that the server has been resized. This has already come in handy, like when I thought of Emacs APAC as being on Saturday, but it was actually on Friday my time.
I have another Emacs Lisp block that I use to
retrieve all the info and update the list of
meetups. I can add (goto-char (org-find-property
"CUSTOM_ID" "crontab")) to find this section and
use org-babel-execute-subtree to execute all the
code blocks. That makes it an automatic part of my
process for updating the Emacs Calendar and Emacs
News. Here's the code that does the calendar part
(Org source):
(defvar my-emacswiki-from-emacs nil "Set to nil when the wiki needs more manual intervention.")
(defun my-prepare-calendar-for-export ()
(interactive)
(with-current-buffer (find-file-noselect "~/sync/emacs-calendar/README.org")
(save-restriction
(widen)
(goto-char (point-min))
(re-search-forward "#\\+NAME: event-summary")
(org-ctrl-c-ctrl-c)
(org-export-to-file 'html "README.html")
;; (unless my-laptop-p (my-schedule-announcements-for-upcoming-emacs-meetups))
;; update the crontab
(goto-char (org-find-property "CUSTOM_ID" "crontab"))
(org-babel-execute-subtree)
(when (and my-laptop-p t)
(org-babel-goto-named-result "event-summary")
(re-search-forward "^- ")
(goto-char (match-beginning 0))
(let* ((events (org-babel-read-result))
(result (mapconcat (lambda (s) (concat "* " s "\n")) events "")))
(if my-emacswiki-from-emacs
(progn
(oddmuse-edit "EmacsWiki" "Usergroups")
(goto-char (point-min))
(delete-region (progn (re-search-forward "== Upcoming events ==\n\n") (match-end 0))
(progn (re-search-forward "^$") (match-beginning 0)))
(save-excursion (insert result)))
(kill-new result)
(browse-url "https://www.emacswiki.org/emacs?action=edit;id=Usergroups")
(message "%s" result)))))))
(my-prepare-calendar-for-export)
I used a similar technique to generate the
EmacsConf crontabs for automatically switching to
the next talk. For that one, I used Emacs Lisp to
write the files directly instead of using the
:file header argument for Org Mode source
blocks. That made it easier to loop over multiple
files.
Hmm. Come to think of it, the technique of "go to
a specific subtree and then execute it" is pretty
powerful. In the past, I've found it handy to
execute source blocks by name. Executing a subtree
by custom ID is even more useful because I can
easily mix source blocks in different languages or
include other information. I think that's worth
adding a my-org-execute-subtree-by-custom-id
function to my Emacs configuration. Combined with
an elisp: link, I can make links that execute
functional blocks that might even be in different
files. That could be a good starting point for a
dashboard.
I love the way Emacs can easily work with files and scripts in different languages on different computers, and how it can help me with times and time zones too. This code should help me avoid brain hiccups and calendar mixups so that people can just enjoy getting together. Now I don't have to worry about whether I remembered to set up cron entries and if I did the math right for the times. We'll see how it holds up!