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)

Watch PyPI releases with a Forgejo Action

The Arch Linux package monitor works well, so I built the same thing for PyPI. It is a separate repository, pypi-release-monitor, with the same shape: a packages.yaml, a state/ folder and a daily Forgejo Action.

Why

The trigger was datasette-auth-osm. Its demo now runs on the Datasette 1.0 alpha, and a new alpha could break it without me noticing. So I want to know when one is out, to check if the APIs changed. Apart from the demo, I want to keep track of the Datasette alphas in general.

Dependabot would open pull requests for this, but there are too many of them.

Pre-releases

The list of packages looks like the Arch one, with an optional flag:

packages:
  - name: datasette
    prereleases: true

Without the flag the version comes from https://pypi.org/pypi/<name>/json as .info.version. That field only shows stable releases, so for Datasette it says 0.65.x and never an alpha. With prereleases: true the newest upload counts instead, skipping yanked releases:

current=$(printf '%s' "$json" | jq -r '
  .releases | to_entries
  | map(select(.value | length > 0 and all(.yanked | not)))
  | max_by(.value | map(.upload_time_iso_8601) | min) | .key')

Picking the newest upload avoids comparing version strings like 1.0a9 and 1.0a41 in jq. The rest is copied from the Arch monitor: compare with state/<name>.txt, record first-seen packages silently, commit the state back.

The check lives in check.sh instead of inline in the workflow. Without a token it only prints what it would send, so I can run it locally.

Messages to Matrix

Notifications go to Matrix with one PUT, as in the Matrix status messages from a cron job post. A message looks like this:

PyPI: datasette 1.0a40 -> 1.0a41 https://pypi.org/project/datasette/1.0a41/

One bot account posts into two rooms, pypi-updates and archlinux-updates. In Matrix the room is what I can mute, so two rooms keep the two streams apart.

The Arch monitor moved too

The Arch Linux package monitor used to send its messages to ntfy. It now posts into archlinux-updates. That leaves ntfy for alarms only.

Packer, Ansible and DietPi for my Raspberry PIs

This is an update to Automatically build Raspberry PIs using Ansible and Packer Image Building for RaspiOS Bookworm. The idea is the same: Packer builds an SD card image, Ansible puts wifi, SSH key, password and Tinc VPN for one specific PI into it, and the secrets are in Ansible Vault. What changed is the Packer builder and, for one Pi Zero 2 W, the operating system.

Switching the Packer builder

At the end of 2023, Packer 1.10 in packer-builder-arm broke the Ansible provisioner. I pinned the last working Docker image for almost two years. For a newer RaspiOS (Trixie) I switched to packer-plugin-cross, which comes as a ready Docker image. The configs are in HCL now.

Ansible runs inside the image now: Packer installs ansible-core in QEMU, runs the playbook with ansible-local and removes Ansible again.

DietPi on a Pi Zero 2 W

The Pi Zero 2 W has only 512MB of memory, so I tried DietPi. I first used the 32-bit image, but Hermes Agent does not install on 32-bit. The Pi has a 64-bit CPU, so I switched to the current 64-bit one: DietPi_RPi234-ARMv8-Trixie.

DietPi applies most settings from /boot/dietpi.txt on the first boot, so the playbook only fills that file and /boot/dietpi-wifi.txt. The SSH key is added to root and dietpi.

Hermes Agent is a bit much for the Pi Zero 2 W. For encrypted Matrix it compiles python-olm on the Pi, which the Zero 2 W is absolutely not suited for. With its web dashboard running the Pi swaps and answers very slowly, so I turned the dashboard off. So currently Hermes is only answering via Matrix.

On my other old Pi Zeros I have not switched away from 32-bit RaspiOS yet, but I will when they need an update. Everything is in the same repository as before.

Pushing to Forgejo from DietPi

DietPi uses Dropbear instead of OpenSSH, so there is no ssh and no ssh-keygen on the Pi. The client is dbclient and keys are generated with dropbearkey:

dropbearkey -t ed25519 -f ~/.ssh/id_dropbear
dropbearkey -y -f ~/.ssh/id_dropbear | grep '^ssh-'

dropbearkey writes the private key in its own format and only prints the public key, there is no .pub file. I added the public key as a deploy key with write access to the repository in Forgejo.

dbclient does not read ~/.ssh/config and git calls ssh by default, which fails with cannot run ssh: No such file or directory. Setting core.sshCommand in the repository fixes that:

git remote set-url origin ssh://git@forgejo.example.ts.net/user/repository.git
git config core.sshCommand "dbclient -i ~/.ssh/id_dropbear"