- C# 79.7%
- Liquid 12.3%
- JavaScript 8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
/timetable/tomorrow serves the same response shape one calendar day later, so a second plugin instance can show tomorrow's lessons with the very same markup. Both routes share one handler, so the token check and the live/last-good/fallback chain exist once. "Tomorrow" is strictly today + 1: on a Friday the view is a Saturday and reads "Keine Schule morgen". The response carries a day_label (heute/morgen) that the templates print in the title bar and the service uses in the no-school note, which keeps the wording server-side and testable. Today and tomorrow get separate last-good caches (TimetableCaches): sharing one slot would let a stale today payload appear under the "morgen" heading with the wrong lessons. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
| deploy | ||
| docs/superpowers | ||
| src/TrmnlPlugins.Web | ||
| templates | ||
| tests/TrmnlPlugins.Tests | ||
| tools/preview | ||
| .gitignore | ||
| AGENTS.md | ||
| global.json | ||
| README.md | ||
| TrmnlPlugins.sln | ||
my-trmnl-plugins
A single .NET service that backs four TRMNL plugins: garbage
collection, school timetable, Bud Spencer & Terence Hill quotes, and Tagesschau news.
They share the same tiny plumbing — a "never break the screen, always return HTTP 200
with a fallback" convention, a last-good response cache, and an optional ?token=
shared secret — so they live in one service with one deployment.
Endpoints
| Method | Path | Auth | Data source |
|---|---|---|---|
| GET | /health |
— | static ok |
| GET | /garbage |
optional ?token= |
CalDAV calendar (live) |
| GET | /timetable |
optional ?token= |
timetable.json |
| GET | /timetable/tomorrow |
optional ?token= |
timetable.json |
| GET | /quote |
optional ?token= |
embedded quotes.json |
| GET | /tagesschau |
optional ?token= |
Tagesschau API (live) |
Each data endpoint always answers 200 — on upstream failure it serves the last good
response, or a German fallback message. A missing or empty config section degrades
only that plugin; the others keep working.
Features
- Garbage collection (
/garbage) — reads a CalDAV calendar, expands recurrences, and returns the soonest upcoming pickup per bin type with pre-formatted German date labels. - School timetable (
/timetable) — per-kid daily lessons from atimetable.jsonconfig file, with subject emphasis/reminders and no-school (holiday) handling./timetable/tomorrowreturns the same shape for the next calendar day, so a second plugin instance can show tomorrow's lessons with the very same markup: the response carries aday_label(heute/morgen) that the template prints in the title bar and the service uses in the no-school note. Strictly today + 1, so on a Friday the tomorrow view is a Saturday and reads "Keine Schule morgen 🎉". - Quotes (
/quote) — a random movie quote from the Bud Spencer & Terence Hill films (note the single-r "Terence"), drawn without back-to-back repeats. - Tagesschau news (
/tagesschau) — the latest German Tagesschau headlines (topline, title, teaser, and a 16:9 teaser image) from the official Tagesschau API. Region and ressort (category) are chosen per request via URL query parameters. Non-story items (videos/webviews) are dropped; breaking news is flagged. The full-screen template shows the top two stories side by side with their photos; stories without an image fall back to text.
Configuration
One appsettings.json with a section per feature (Garbage, Timetable, Quotes,
Tagesschau). Secrets and machine-specific paths go in a gitignored
appsettings.Local.json (copy the shipped
src/TrmnlPlugins.Web/appsettings.Local.json.example) or in Section__Key
environment variables (e.g. Garbage__CalDavPassword).
Config is not part of the publish output.
appsettings.jsonandappsettings.Local.jsonare excluded fromdotnet publish(see theCopyToPublishDirectory="Never"items in the csproj), so redeploying by copying the whole publish folder can never overwrite the server's production config — and local dev secrets are never shipped. Manage config on the server instead (see Deploy).
- Garbage:
CalDavUrl,CalDavUsername,CalDavPassword,LookaheadDays(default 90), optionalAccessToken. - Timetable:
Pathto the timetable JSON (relative paths resolve against the content root; point it at a private file outside the repo on the server), optionalAccessToken. The committedFeatures/Timetable/timetable.jsonis sample data.Timetable:Holidays— optional live holiday/vacation source via OpenHolidays. SetEnabled: trueand aSubdivisionCode(ISO 3166-2, e.g.DE-BYfor Bavaria) plusCountryIsoCodeandLanguageIsoCode. School vacations and public holidays are then fetched (cachedCacheHours, default 12) and merged with your manualnoSchoolentries. Disabled by default; if the API is unreachable the timetable falls back to the last good fetch, then to the manual list — it never breaks.
- Quotes: optional
AccessToken. Quotes data is compiled in as an embedded resource. - Tagesschau: region and ressort are query parameters, not config — set them on the
polling URL (
?regions=2,3&ressort=inland). TheTagesschausection holds server-side settings only:BaseUrl(defaulthttps://www.tagesschau.de/api2u), optionalAccessToken,DefaultRegions/DefaultRessort(used when the query omits them),MaxItems(default 4), andCacheMinutes(default 5, the freshness window before re-fetching). Region codes are1–16; ressort is one ofinland, ausland, wirtschaft, sport, video, investigativ, wissen.
Build & test
export PATH="$PATH:$HOME/.dotnet"
dotnet test
dotnet publish src/TrmnlPlugins.Web -c Release -o out
Deploy (Uberspace)
Follows https://blog.lars-richter.dev/deploying-net-services-on-uberspace/:
dotnet publish src/TrmnlPlugins.Web -c Release -r linux-x64 --self-contained false \
-p:PublishReadyToRun=true -o publish
# copy publish/ to ~/bin/trmnl-plugins on Uberspace (config is NOT in publish, so
# this won't touch the server's appsettings.json), then add
# ~/etc/services.d/trmnl.ini (see deploy/supervisord.conf), then:
supervisorctl reread && supervisorctl update
uberspace web backend set <your-host> --http --port 5000
The flags precompile for the server to cut cold-start time on Uberspace's shared
CPU: PublishReadyToRun compiles IL to native ahead of time (no JIT at startup),
which requires a runtime identifier — hence -r linux-x64. Keep --self-contained false so it stays framework-dependent (small, uses the runtime installed on the
server). The RID is required and must be linux-x64: ReadyToRun otherwise
defaults to the host's RID, so publishing from a Mac without it would build
macOS-native images that don't match the server. crossgen2 cross-compiles the
linux-x64 images fine from any host, including Apple Silicon (the first such
publish downloads the linux-x64 packages). dotnet run/build/test are
unaffected — ReadyToRun only applies at publish time.
Config lives on the server, next to the binary in ~/bin/trmnl-plugins. First
deploy only: create ~/bin/trmnl-plugins/appsettings.json there with your
production settings (the app reads it from the content root at startup). Later
deploys leave it in place. Prefer a clean publish folder (rm -rf publish first)
so stale files from an earlier build — including any old appsettings*.json — don't
get copied up.
The web backend terminates HTTPS (Let's Encrypt) and proxies to the local port, so
the app binds http://localhost:5000. TZ=Europe/Berlin is required for correct
German dates and is set in the supervisord program.
TRMNL setup
For each plugin, set its Polling URL to the new path and paste the matching Liquid:
- Garbage:
https://<host>/garbage—templates/garbage/markup_full.liquidandtemplates/garbage/markup_half_vertical.liquid; refresh every 12h. - Timetable:
https://<host>/timetable—templates/timetable/markup_full.liquidandtemplates/timetable/markup_half_vertical.liquid(kids stacked instead of side by side; designed for the TRMNL X); refresh every 12h. - Quotes:
https://<host>/quote—templates/quotes/template.liquid; refresh on your desired interval. - Tagesschau:
https://<host>/tagesschau—templates/tagesschau/markup_full.liquidandtemplates/tagesschau/markup_half_vertical.liquid; refresh every 1–2h. Add TRMNL form fields for region/ressort and reference them in the polling URL, e.g.https://<host>/tagesschau?regions={{ regions }}&ressort={{ ressort }}.
Append ?token=… to the URL if you configured that feature's AccessToken. Use each
templates/<plugin>/sample-response.json as the sample data in the TRMNL editor.