ESP32-CAM: fixing the flash timing

This is a follow-up to ESPHome: ESP32-CAM. Every day at 10:30 a Raspberry PI sends the latest photo of my heating sensor into a Matrix room. For the last few days the image looked like this:

img1

The image is cut off, the JPEG is incomplete. This worked for months, but in the last weeks more and more images broke during the transfer. The flash was switched on in on_image, so the ESP32 decided by itself when the LED is on and the PI just downloaded whatever it got.

Now the on_image block is removed from the ESPHome config and the PI switches the flash: on, wait 2 seconds, request the image, off. The ESPHome web server has a REST endpoint for the light.

The new capture.sh on the PI:

#!/bin/sh
ESP=192.168.0.11

cd /home/pi/images
P="`date +%Y`/`date +%m`"
mkdir -p $P
OUT=$P/`date +%s`.jpg
TMP=capture.tmp

# A finished JPEG ends with the EOI marker ff d9.
complete() {
    [ "$(tail -c 2 "$1" 2>/dev/null | od -An -tx1 | tr -d ' \n')" = "ffd9" ]
}

flash() { curl -s -m 5 -d '' "http://$ESP/light/Camera%20flash/$1" > /dev/null; }
flash turn_on
trap 'flash turn_off; rm -f $TMP' EXIT
sleep 2

# The first two snapshots are buffered or still adjusting to the flash.
for i in 1 2; do curl -s -m 20 -o /dev/null http://$ESP:8081/; done
for i in 1 2 3; do
    curl -s -m 20 -o $TMP http://$ESP:8081/ && complete $TMP && break
    rm -f $TMP
    sleep 2
done
[ -f $TMP ] || { echo "no complete image from $ESP" >&2; exit 1; }

mv $TMP $OUT
ln -sf ./$OUT current.jpg

Only a complete image replaces current.jpg, so Matrix gets the current photo instead of a broken one. An example of a working image:

img2

An org-mode inbox fed from a Matrix room

The bots in Matrix status messages from a cron job and A daily morning summary in Matrix only write into Matrix. This one goes the other way: I write a message into a Matrix room, and it ends up as a TODO in an org-mode file in my notes repo.

The setup is a Forgejo Action that runs once an hour. It reads the room, adds every new message from me to matrix-inbox.org, commits and pushes the file, and then reacts to the message with a checkmark.

img1

The bot has its own account @org-bot on my Conduit homeserver. The access token is stored as a Forgejo secret.

The room is not encrypted to reduce complications with olm/megolm persistence.

Reading the room

The script uses the plain client-server API with httpx. It fetches the last 100 events of the room with /messages?dir=b, so the newest event comes first. Then it collects the bot's checkmarks and walks back through my messages until it hits one that is already acked:

acked = {
    ev["content"]["m.relates_to"]["event_id"]
    for ev in events
    if ev["type"] == "m.reaction" and ev["sender"] == BOT
}

new = []
for ev in events:
    if ev["type"] != "m.room.message" or ev["sender"] != OWNER:
        continue
    if ev["event_id"] in acked:
        break  # everything older is acked too
    new.append(ev)

The script skips edits, otherwise fixing a typo in Matrix would add a new entry.

The inbox file

The first line of a message becomes the headline, every further line becomes the body. Entries are grouped by day, newest day on top and newest entry on top within a day:

#+STARTUP: showeverything
#+TITLE: Matrix inbox

* matrix inbox entries

** 2026-10-31

*** TODO prepare a draft blog post for the matrix org bot
*** TODO check the airpressure on the cube29

Ack only after the push

The order matters: if the bot reacts before the push and the push fails, the message is acked but the entry is lost with the runner. So the script has two modes, matrix-inbox.py to add the messages and matrix-inbox.py ack to react, and the git part stays in the workflow between them:

steps:
  - uses: actions/checkout@v7
    with:
      token: ${{ secrets.GITHUB_TOKEN }}
  - name: Install uv
    uses: https://github.com/astral-sh/setup-uv@v6
  - name: Add new messages to inbox
    run: uv run matrix-inbox.py
  - name: Commit and push
    run: |
      git config user.name "Automated"
      git config user.email "actions@forgejo.local"
      git remote set-url origin https://oauth2:${{ secrets.GITHUB_TOKEN }}@forgejo.example.com/${{ github.repository }}
      git add matrix-inbox.org
      git diff --staged --quiet || git commit -m "matrix inbox - $(TZ=Europe/Berlin date +'%Y-%m-%d %H:%M')"
      git push
  - name: Ack messages
    run: uv run matrix-inbox.py ack

The event ids of the added messages are handed from the first uv run matrix-inbox to the second one via a small file in $RUNNER_TEMP.

A daily morning summary in Matrix

The Raspberry Pi I set up in Packer, Ansible and DietPi for my Raspberry PIs runs the Hermes agent and is already connected to Homeassistant and Matrix. So it is the best place for a small summary every morning at 7:00.

What I want to know in the morning:

  • Which waste is collected today and tomorrow.

  • The temperature range for today and when it rains.

  • The battery level of my car.

  • When my local library is open today.

  • Is there a VfB home game in Stuttgart today -- then I avoid public transport.

  • Are there unexpected issues with the S-Bahn or SSB, not the planned roadworks.

There is no section about my appointments or plans for the day. Hermes has no access to my calendar or my todo lists, so it cannot write about them.

This is a single Python script started by cron on the Pi. No LLM is involved, the agent is only used for sending the message.

img1

The message goes to the Matrix room I mainly use for chatting with Hermes, so I can ask follow-up questions right there. For example the summary listed detours because of a demonstration, so I asked which demonstration it was. Hermes searched the web and answered with links to the Stuttgarter Nachrichten and the SSB:

img2

Homeassistant

The Hermes agent already has a Homeassistant token in its .env, so the script reads HASS_URL and HASS_TOKEN from there. Two REST API calls are enough:

class HomeAssistant:
    def __init__(self, url, token):
        self.url = url.rstrip("/")
        self.headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

    def state(self, entity_id):
        return fetch_json(f"{self.url}/api/states/{entity_id}", headers=self.headers)

    def forecast(self, kind):
        resp = fetch_json(
            f"{self.url}/api/services/weather/get_forecasts?return_response",
            data={"entity_id": WEATHER_ENTITY, "type": kind},
            headers=self.headers,
        )
        return resp["service_response"][WEATHER_ENTITY]["forecast"]

The waste sensors are the ones from Homeassistant Tracking Waste Collection Dates. Their state is a date, so the script only compares it with today and tomorrow. The car battery is a plain state too (sensor.r5_battery and sensor.r5_battery_autonomy for the range).

The weather forecast comes from the weather.get_forecasts service. The script calls it twice: daily for the min/max temperature and hourly for the rain. Consecutive hours with rain are grouped, so the message shows 22-24 Uhr 0.6 mm instead of a list of single hours.

Library opening hours

The opening hours of my local library branch are on its page on the Stadtbibliothek website. The hours are plain HTML:

<h2>Öffnungszeiten</h2><strong>Dienstag</strong> 14:00 - 19:00 Uhr
<br><strong>Mittwoch</strong> 10:00 - 15:00 Uhr

A regular expression on the block after the heading returns a dict of weekday and hours. The hours rarely change, so fetching them every morning is a bit much and I may cache them later.

VfB home games

OpenLigaDB has an endpoint that returns the matches of a team for a number of weeks in the past and the future. VfB Stuttgart has the team id 16 and the result contains Bundesliga, DFB-Pokal and Champions League matches. A home game is a match where VfB is team1:

def section_vfb(today):
    matches = fetch_json(f"https://api.openligadb.de/getmatchesbyteamid/{VFB_TEAM_ID}/1/1")
    home = [
        m for m in matches
        if m["team1"]["teamId"] == VFB_TEAM_ID and to_local(m["matchDateTimeUTC"]).date() == today
    ]
    return f"⚽ **VfB {'' if home else 'kein '}Heimspiel heute!**"

S-Bahn and SSB

The VVS has an EFA endpoint (XML_ADDINFO_REQUEST) that returns all current info messages as JSON. On the day I tried it there were 414 messages, most of them with AlertCause: roadworks. After removing roadworks and broken elevators, the script keeps only messages for S-Bahn, U-Bahn and SSB lines that are active today. To filter out long planned changes, a message has to be published at most one day before it starts. This is a heuristic and I will see if it is too noisy over the next weeks.

Sending the message

Hermes already has a CLI command for this, hermes send, which reuses the credentials of the agent:

subprocess.run([HERMES, "send", "--to", MATRIX_TARGET, "--quiet", "--file", "-"],
               input=summary, text=True, check=True)