├── _plugins
├── epl-fixtures
│ ├── .env.example
│ ├── full.png
│ ├── preview
│ │ └── full.png
│ ├── settings.yml
│ └── views
│ │ ├── half_vertical.liquid
│ │ ├── quadrant.liquid
│ │ ├── full.liquid
│ │ └── half_horizontal.liquid
├── currency-exchange
│ ├── .env.example
│ ├── preview
│ │ └── full.png
│ ├── sample.json
│ ├── settings.yml
│ └── views
│ │ ├── full.liquid
│ │ ├── quadrant.liquid
│ │ ├── half_horizontal.liquid
│ │ └── half_vertical.liquid
├── random-fact
│ ├── .env.example
│ ├── preview
│ │ └── full.png
│ ├── views
│ │ ├── half_horizontal.liquid
│ │ ├── half_vertical.liquid
│ │ ├── quadrant.liquid
│ │ └── full.liquid
│ ├── sample.json
│ └── settings.yml
├── epl-my-team
│ ├── .env.example
│ ├── preview
│ │ └── full.png
│ ├── settings.yml
│ └── views
│ │ ├── half_vertical.liquid
│ │ ├── full.liquid
│ │ ├── quadrant.liquid
│ │ └── half_horizontal.liquid
├── random-joke
│ ├── .env.example
│ ├── preview
│ │ └── full.png
│ ├── sample.json
│ ├── views
│ │ ├── full.liquid
│ │ ├── half_horizontal.liquid
│ │ ├── half_vertical.liquid
│ │ └── quadrant.liquid
│ └── settings.yml
├── trmnl-broadcast
│ ├── sample.json
│ ├── preview
│ │ └── full.png
│ ├── settings.yml
│ └── views
│ │ ├── full.liquid
│ │ ├── quadrant.liquid
│ │ ├── half_vertical.liquid
│ │ └── half_horizontal.liquid
├── NTFY
│ ├── ntfy.sh.png
│ ├── Preview
│ │ ├── full.png
│ │ └── half_horizontal.png
│ ├── settings.yml
│ ├── views
│ │ ├── half_vertical.liquid
│ │ ├── quadrant.liquid
│ │ ├── half_horizontal.liquid
│ │ └── full.liquid
│ ├── sample.json
│ └── README.md
├── code-clock
│ ├── preview
│ │ └── full.png
│ ├── settings.yml
│ ├── views
│ │ ├── custom.css
│ │ ├── half_vertical.liquid
│ │ ├── half_horizontal.liquid
│ │ ├── quadrant.liquid
│ │ └── full.liquid
│ └── sample.json
├── my-agenda
│ ├── preview
│ │ └── full.png
│ ├── settings.yml
│ ├── sample.json
│ └── views
│ │ ├── quadrant.liquid
│ │ ├── half_horizontal.liquid
│ │ ├── half_vertical.liquid
│ │ └── full.liquid
├── stu-fractals
│ ├── sample.json
│ ├── settings.yml
│ └── views
│ │ ├── full.liquid
│ │ ├── quadrant.liquid
│ │ ├── half_horizontal.liquid
│ │ └── half_vertical.liquid
├── home-assistant-trmnl
│ ├── images
│ │ ├── HACS.png
│ │ ├── add-label.png
│ │ ├── automation.png
│ │ └── checkboxes-mode.png
│ ├── preview
│ │ └── full.png
│ ├── releases
│ │ └── home-assistant-trmnl-plugin-1739494280682.zip
│ ├── settings.yml
│ ├── views
│ │ ├── half_vertical.liquid
│ │ ├── half_horizontal.liquid
│ │ ├── quadrant.liquid
│ │ └── full.liquid
│ ├── README.md
│ └── sample.json
├── wind-speed-direction
│ ├── preview
│ │ └── full.png
│ ├── settings.yml
│ ├── views
│ │ ├── half_vertical.liquid
│ │ ├── quadrant.liquid
│ │ ├── half_horizontal.liquid
│ │ └── full.liquid
│ └── sample.json
└── untested-jellyfin-sample-for-rg
│ ├── settings.yml
│ ├── sample.json
│ └── views
│ └── full.liquid
├── preview.png
├── public
├── trmnl-logo.png
├── icons8-plug-16.png
└── display-preview.html
├── dev-docs
├── description.example.html
├── title.example.html
├── clamp.example.html
├── title-bar.example.html
├── label.example.html
├── table.example.html
└── design-system-demo-full.html
├── DEPLOYMENT.md
├── scripts
├── serve-plugin.sh
├── clean.sh
├── download-browsers.sh
├── push-to-dockerhub.sh
├── run.sh
├── docker-run.sh
└── download-cached-cdn-files.sh
├── nodemon.json
├── device.json
├── config.json
├── .env.example
├── home-assistant-trmnl
└── README.md
├── .github
└── workflows
│ ├── fly.toml
│ ├── deploy-fly.yml
│ └── docker.yml
├── .dockerignore
├── package.json
├── middleware
└── auth.js
├── .gitignore
├── sample.json
├── .vscode
└── launch.json
├── LICENSE.md
├── Dockerfile
├── serve-plugin.sh
├── SPECIFICATION.md
├── admin
├── index.html
└── index.js
├── routes
└── api.js
├── download-assets.js
└── README.md
/_plugins/epl-fixtures/.env.example:
--------------------------------------------------------------------------------
1 | authorization=bearer 123
--------------------------------------------------------------------------------
/_plugins/currency-exchange/.env.example:
--------------------------------------------------------------------------------
1 | apikey=cur_live_aaaaaa000
--------------------------------------------------------------------------------
/_plugins/random-fact/.env.example:
--------------------------------------------------------------------------------
1 | # No secrets needed for this plugin
--------------------------------------------------------------------------------
/_plugins/epl-my-team/.env.example:
--------------------------------------------------------------------------------
1 | HEADERS=
2 | additional_query_string_params=
--------------------------------------------------------------------------------
/_plugins/random-joke/.env.example:
--------------------------------------------------------------------------------
1 | HEADERS=
2 | additional_query_string_params=
--------------------------------------------------------------------------------
/preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/preview.png
--------------------------------------------------------------------------------
/public/trmnl-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/public/trmnl-logo.png
--------------------------------------------------------------------------------
/_plugins/trmnl-broadcast/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "text": "Reminder to take out the trash on Friday!"
3 | }
--------------------------------------------------------------------------------
/_plugins/NTFY/ntfy.sh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/NTFY/ntfy.sh.png
--------------------------------------------------------------------------------
/public/icons8-plug-16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/public/icons8-plug-16.png
--------------------------------------------------------------------------------
/_plugins/NTFY/Preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/NTFY/Preview/full.png
--------------------------------------------------------------------------------
/_plugins/epl-fixtures/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/epl-fixtures/full.png
--------------------------------------------------------------------------------
/_plugins/code-clock/preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/code-clock/preview/full.png
--------------------------------------------------------------------------------
/_plugins/my-agenda/preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/my-agenda/preview/full.png
--------------------------------------------------------------------------------
/_plugins/epl-fixtures/preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/epl-fixtures/preview/full.png
--------------------------------------------------------------------------------
/_plugins/epl-my-team/preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/epl-my-team/preview/full.png
--------------------------------------------------------------------------------
/_plugins/random-fact/preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/random-fact/preview/full.png
--------------------------------------------------------------------------------
/_plugins/random-joke/preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/random-joke/preview/full.png
--------------------------------------------------------------------------------
/_plugins/NTFY/Preview/half_horizontal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/NTFY/Preview/half_horizontal.png
--------------------------------------------------------------------------------
/_plugins/stu-fractals/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Stu Fractals",
3 | "image_url": "https://stu-fractal-worker.stuey.workers.dev/"
4 | }
--------------------------------------------------------------------------------
/_plugins/trmnl-broadcast/preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/trmnl-broadcast/preview/full.png
--------------------------------------------------------------------------------
/_plugins/currency-exchange/preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/currency-exchange/preview/full.png
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/images/HACS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/home-assistant-trmnl/images/HACS.png
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/home-assistant-trmnl/preview/full.png
--------------------------------------------------------------------------------
/_plugins/wind-speed-direction/preview/full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/wind-speed-direction/preview/full.png
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/images/add-label.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/home-assistant-trmnl/images/add-label.png
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/images/automation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/home-assistant-trmnl/images/automation.png
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/images/checkboxes-mode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/home-assistant-trmnl/images/checkboxes-mode.png
--------------------------------------------------------------------------------
/_plugins/random-joke/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "general",
3 | "setup": "Where do sheep go to get their hair cut?",
4 | "punchline": "The baa-baa shop.",
5 | "id": 287
6 | }
--------------------------------------------------------------------------------
/dev-docs/description.example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | This is a sample description text.
4 |
5 |
--------------------------------------------------------------------------------
/dev-docs/title.example.html:
--------------------------------------------------------------------------------
1 |
2 | Default Title
3 |
4 |
5 | Small Title
6 |
7 |
--------------------------------------------------------------------------------
/DEPLOYMENT.md:
--------------------------------------------------------------------------------
1 | The sample is deployed to fly.io
2 |
3 | To setup fly.io, run the following commands:
4 |
5 | ## Mac
6 | ```
7 | brew install flyctl
8 | fly login
9 | fly deploy
10 | ```
11 |
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/releases/home-assistant-trmnl-plugin-1739494280682.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/HEAD/_plugins/home-assistant-trmnl/releases/home-assistant-trmnl-plugin-1739494280682.zip
--------------------------------------------------------------------------------
/_plugins/random-fact/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
2 | {{ text }}
3 |
4 |
5 | Random Fact
6 |
--------------------------------------------------------------------------------
/_plugins/random-fact/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
2 | {{ text }}
3 |
4 |
5 | Random Fact
6 |
--------------------------------------------------------------------------------
/scripts/serve-plugin.sh:
--------------------------------------------------------------------------------
1 | # Find all directories containing settings.yml
2 | plugins=()
3 | while IFS= read -r dir; do
4 | plugins+=("$(basename "$(dirname "$dir")")")
5 | done < <(find ./_plugins -name "settings.yml" -type f)
--------------------------------------------------------------------------------
/_plugins/random-fact/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "c5f07c48c4b4b8b5",
3 | "text": "Bananas are berries, but strawberries aren't.",
4 | "source": "random",
5 | "language": "en",
6 | "permalink": "https://uselessfacts.jsph.pl/c5f07c48c4b4b8b5"
7 | }
--------------------------------------------------------------------------------
/scripts/clean.sh:
--------------------------------------------------------------------------------
1 | # clean the tmp directory
2 | rm -rf tmp/*
3 |
4 | # clean the cache directory
5 | rm -rf cache/*
6 |
7 | # clean node_modules directory
8 | rm -rf node_modules/*
9 |
10 | # clean package-lock.json
11 | rm -f package-lock.json
12 |
13 |
--------------------------------------------------------------------------------
/_plugins/random-fact/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
2 | {{ text }}
3 |
4 |
5 | Random Fact
6 |
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: webhook
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: ''
8 | polling_headers: ''
9 | custom_fields:
10 | name: Home Assistant TRMNL
11 | refresh_interval: 60
12 |
--------------------------------------------------------------------------------
/_plugins/currency-exchange/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "meta": {
3 | "last_updated_at": "2025-01-31T23:59:59Z"
4 | },
5 | "data": {
6 | "AUD": {
7 | "code": "AUD",
8 | "value": 1.6098402959
9 | },
10 | "USD": {
11 | "code": "USD",
12 | "value": 1
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/scripts/download-browsers.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Create tmp directory
4 | mkdir -p tmp
5 |
6 | echo "Installing browsers..."
7 |
8 | # Install Puppeteer and Chrome
9 | echo "Installing Puppeteer and Chrome..."
10 | npm install puppeteer
11 |
12 | echo "Installation complete. Browser assets installed."
--------------------------------------------------------------------------------
/_plugins/random-joke/views/full.liquid:
--------------------------------------------------------------------------------
1 |
2 |
{{ setup }}
3 |
{{ punchline }}
4 |
5 |
6 | Random Joke
7 |
--------------------------------------------------------------------------------
/_plugins/random-joke/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
2 |
{{ setup }}
3 |
{{ punchline }}
4 |
5 |
6 | Random Joke
7 |
--------------------------------------------------------------------------------
/_plugins/random-joke/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
2 |
{{ setup }}
3 |
{{ punchline }}
4 |
5 |
6 | Random Joke
7 |
--------------------------------------------------------------------------------
/_plugins/random-fact/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: polling
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://uselessfacts.jsph.pl/random.json?language=en
8 | polling_headers: 'content-type: application/json'
9 | name: Random Fact
10 | description: Displays a random fact
11 | refresh_interval: 3600
--------------------------------------------------------------------------------
/_plugins/random-joke/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
2 |
{{ setup }}
3 |
{{ punchline }}
4 |
5 |
6 | Random Joke
7 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": [
3 | "app.js",
4 | "config.json",
5 | "_plugins/**/*.html",
6 | "_plugins/**/*.yml",
7 | "public/**/*",
8 | "_plugins/**/plugin.json",
9 | "device.json"
10 | ],
11 | "ext": "js,json,html,css",
12 | "ignore": [
13 | "node_modules/*",
14 | "*.test.js",
15 | "cache/**/*"
16 | ]
17 | }
--------------------------------------------------------------------------------
/_plugins/stu-fractals/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: static
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://official-joke-api.appspot.com/random_joke # Placeholder URL for preview
8 | polling_headers: ''
9 | name: Stu Fractals
10 | description: Display beautiful fractal images
11 | version: 1.0.0
12 | author: Stu
--------------------------------------------------------------------------------
/dev-docs/clamp.example.html:
--------------------------------------------------------------------------------
1 |
2 | This text is clamped to a single line. Any overflow will be truncated with an ellipsis.
3 |
4 |
5 |
6 | This text is clamped to three lines. It will show up to three lines of text before truncating with an ellipsis. Lorem ipsum dolor sit amet, consectetur adipiscing elit.
7 |
--------------------------------------------------------------------------------
/device.json:
--------------------------------------------------------------------------------
1 | {
2 | "user": {
3 | "first_name": "Hermione",
4 | "last_name": "Granger",
5 | "locale": "en",
6 | "time_zone": "Sydney",
7 | "time_zone_iana": "Australia/Sydney",
8 | "utc_offset": 39600
9 | },
10 | "device": {
11 | "friendly_id": "AAA0A",
12 | "percent_charged": 94.7,
13 | "wifi_strength": 92
14 | },
15 | "system": {
16 | "timestamp_utc": 1738998185
17 | }
18 | }
--------------------------------------------------------------------------------
/_plugins/random-joke/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: polling
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://official-joke-api.appspot.com/random_joke
8 | polling_headers: "content-type: application/json"
9 | name: Random Joke
10 | description: Displays a random joke with setup and punchline from the Official Joke API. Updates on each refresh to show a new joke.
11 | refresh_interval: 3600
--------------------------------------------------------------------------------
/_plugins/stu-fractals/views/full.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Stu Fractals
8 | Generated Art
9 |
10 |
--------------------------------------------------------------------------------
/dev-docs/title-bar.example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Basic Title Bar
5 |
6 |
7 |
8 |
9 |
10 |
Title Bar with Instance
11 |
Production
12 |
--------------------------------------------------------------------------------
/_plugins/stu-fractals/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Stu Fractals
8 | Generated Art
9 |
10 |
--------------------------------------------------------------------------------
/_plugins/stu-fractals/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Stu Fractals
8 | Generated Art
9 |
10 |
--------------------------------------------------------------------------------
/_plugins/stu-fractals/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Stu Fractals
8 | Generated Art
9 |
10 |
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "designSystem": {
3 | "cssPath": "/css/latest/plugins.css",
4 | "jsPath": "/js/latest/plugins.js",
5 | "imagesPath": "/images",
6 | "fonts": {
7 | "path": "/fonts",
8 | "files": [
9 | "NicoClean-Regular.ttf",
10 | "NicoBold-Regular.ttf",
11 | "NicoPups-Regular.ttf",
12 | "BlockKie.ttf",
13 | "dogicapixel.ttf",
14 | "dogicapixelbold.ttf"
15 | ]
16 | }
17 | },
18 | "version": "0.1"
19 | }
20 |
--------------------------------------------------------------------------------
/_plugins/untested-jellyfin-sample-for-rg/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: static
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://example.com/api/movies # Placeholder URL
8 | polling_headers: ''
9 | name: Jellyfin Sample for RG
10 | description: Displays a list of unwatched movies sorted by rating.
11 | version: 1.0.0
12 | author: "@yourusername"
13 | refresh_interval: 3600 # Assuming hourly update frequency
14 | tags:
15 | - entertainment
16 | - movies
--------------------------------------------------------------------------------
/scripts/push-to-dockerhub.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Set variables
4 | IMAGE_NAME="gitstua/trmnl-plugin-tester"
5 | VERSION=$(node -p "require('./package.json').version")
6 |
7 | # Build the Docker image
8 | echo "Building Docker image..."
9 | docker build -t ${IMAGE_NAME}:${VERSION} .
10 | docker tag ${IMAGE_NAME}:${VERSION} ${IMAGE_NAME}:latest
11 |
12 | # Push to Docker Hub
13 | echo "Pushing to Docker Hub..."
14 | docker push ${IMAGE_NAME}:${VERSION}
15 | docker push ${IMAGE_NAME}:latest
16 |
17 | echo "Done! Image pushed as ${IMAGE_NAME}:${VERSION} and ${IMAGE_NAME}:latest"
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # No environment variables needed for this plugin
2 |
3 | # Cache mode - set to true to download and serve files locally
4 | # When false, files are served directly from usetrmnl.com
5 | USE_CACHE=true
6 |
7 | # Cache path - only used when USE_CACHE=true
8 | # CACHE_PATH=/custom/cache/path
9 |
10 | # Debug mode - uncomment to enable debug endpoint
11 | # DEBUG_MODE=true
12 |
13 | # Source mode - uncomment to use local files instead of downloading
14 | # SOURCE_MODE=true
15 |
16 | # Plugins path - uncomment to override default
17 | # PLUGINS_PATH=/custom/plugins/path
--------------------------------------------------------------------------------
/_plugins/code-clock/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: polling
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://raw.githubusercontent.com/gitstua/trmnl-plugin-dev/refs/heads/main/_plugins/code-clock/sample.json
8 | polling_headers: 'content-type: application/json'
9 | name: Code Clock
10 | description: Displays current time as code snippets embedded in various programming contexts. Updates periodically with new random code samples and time. Note - This is not a realtime clock - it updates when the image is generated.
11 | refresh_interval: 900
--------------------------------------------------------------------------------
/_plugins/currency-exchange/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: polling
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://api.currencyapi.com/v3/latest?currencies=AUD%2CUSD&apikey={{ apikey }}
8 | polling_headers: 'content-type: application/json'
9 | name: Currency Exchange Rates
10 | description: Display current exchange rates for AUD and USD
11 | refresh_interval: 86400
12 | custom_fields:
13 | - keyname: apikey
14 | field_type: string
15 | name: API Key
16 | description: Your currencyapi.com API key
17 | placeholder: your-api-key-here
--------------------------------------------------------------------------------
/_plugins/trmnl-broadcast/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: webhook
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://api.example.com/broadcast?channel={{ channel }}
8 | polling_headers: "Authorization: Bearer {{ AUTH_TOKEN }}\ncontent-type: application/json"
9 | name: TRMNL Broadcast
10 | description: Displays a static text message in a terminal-style interface
11 | refresh_interval: 3600
12 | custom_fields:
13 | - keyname: channel
14 | field_type: string
15 | name: Channel
16 | description: The broadcast channel to subscribe to
17 | placeholder: trmnl-broadcast
--------------------------------------------------------------------------------
/home-assistant-trmnl/README.md:
--------------------------------------------------------------------------------
1 | # Home Assistant TRMNL Plugin
2 |
3 | Display your Home Assistant sensor data in TRMNL. This plugin shows temperature and other sensors in a clean, organized interface.
4 |
5 | - Uses Home Assistant [shell_command](https://www.home-assistant.io/integrations/shell_command/) to send data to TRMNL.
6 | - Uses a [Home Assistant Label](https://www.home-assistant.io/docs/organizing/labels/) `TRMNL` to identify the entities to send to TRMNL via the webhook.
7 |
8 | 
9 |
10 | The docs have moved to [here](../_plugins/home-assistant-trmnl/README.md)
--------------------------------------------------------------------------------
/.github/workflows/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml app configuration file generated for trmnl-plugins on 2025-02-07T19:30:41+11:00
2 | #
3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4 | #
5 |
6 | app = 'trmnl-plugins'
7 | primary_region = 'syd'
8 |
9 |
10 | [build]
11 |
12 | [http_service]
13 | internal_port = 3000
14 | force_https = true
15 | auto_stop_machines = 'stop'
16 | auto_start_machines = true
17 | min_machines_running = 1
18 | processes = ['app']
19 |
20 | [[vm]]
21 | size = 'shared-cpu-1x'
22 |
23 | #[mounts]
24 | # source="trmnl_cache"
25 | # destination="/data/cache"
26 |
--------------------------------------------------------------------------------
/_plugins/random-fact/views/full.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% if text.size < 150 %}
5 | {{ text }}
6 | {% elsif text.size < 300 %}
7 | {{ text }}
8 | {% else %}
9 | {{ text }}
10 | {% endif %}
11 |
12 |
13 |
14 | Random Fact
15 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-fly.yml:
--------------------------------------------------------------------------------
1 |
2 |
3 | name: Fly Deploy
4 | on:
5 | push:
6 | branches:
7 | - main # change to main if needed
8 | workflow_dispatch:
9 | jobs:
10 | deploy:
11 | name: Deploy app
12 | runs-on: ubuntu-latest
13 | concurrency: deploy-group # optional: ensure only one action runs at a time
14 | steps:
15 | - uses: actions/checkout@v4
16 | - uses: superfly/flyctl-actions/setup-flyctl@master
17 | - run: flyctl deploy --remote-only --config fly.toml -a trmnl-plugins
18 | env:
19 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
20 | - run: echo "url=https://trmnl-plugins.fly.dev/" >> $GITHUB_OUTPUT
21 |
22 |
--------------------------------------------------------------------------------
/dev-docs/label.example.html:
--------------------------------------------------------------------------------
1 |
2 | Label
3 | Outline Label
4 | Underline Label
5 | Gray Out Label
6 | Inverted Label
7 |
8 |
9 | Label
10 | Outline Label
11 | Underline Label
12 | Gray Out Label
13 | Inverted Label
14 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Environment files (move these AFTER the .* patterns)
2 | .env
3 | .env.*
4 | **/.env
5 | **/.env.*
6 |
7 | # Any file beginning with a dot
8 | .*
9 | **/.*
10 |
11 | # Add this new pattern to explicitly protect plugin env files
12 | **/plugins/**/.env
13 | plugins/**/.env
14 | */**/plugins/**/.env
15 |
16 | # Other common files to ignore
17 | .git
18 | .gitignore
19 | node_modules
20 | **/node_modules
21 | npm-debug.log
22 | README.md
23 | **/README.md
24 | .DS_Store
25 | **/.DS_Store
26 |
27 | # Temporary folders
28 | **/tmp/
29 | **/tmp/*
30 | tmp/
31 | tmp/*
32 |
33 | # Preview folders
34 | **/preview/
35 | preview/
36 | **/preview/*
37 | preview/*
38 |
39 | # Scripts directory
40 | scripts/
41 | **/scripts/
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "trmnl-plugin-dev",
3 | "version": "0.4.1",
4 | "description": "Development tool for testing TRMNL plugins",
5 | "main": "app.js",
6 | "scripts": {
7 | "start": "node app.js",
8 | "dev": "nodemon app.js"
9 | },
10 | "dependencies": {
11 | "dotenv": "^16.4.7",
12 | "express": "^4.21.2",
13 | "imagemagick": "^0.1.3",
14 | "jimp": "^0.16.2",
15 | "js-yaml": "^4.1.0",
16 | "jszip": "^3.10.1",
17 | "liquidjs": "^9.25.1",
18 | "node-fetch": "^2.6.1",
19 | "puppeteer": "^24.2.0",
20 | "sqlite3": "^5.1.7",
21 | "toml": "^3.0.0"
22 | },
23 | "devDependencies": {
24 | "nodemon": "^2.0.7"
25 | },
26 | "author": "Stu Eggerton",
27 | "license": "SEE LICENSE IN LICENSE.md"
28 | }
29 |
--------------------------------------------------------------------------------
/middleware/auth.js:
--------------------------------------------------------------------------------
1 | const config = require('../config');
2 |
3 | function authMiddleware(req, res, next) {
4 | // Check for an Authorization header (this is a simplified example)
5 | //const token = req.headers.authorization;
6 |
7 | // In a real app, you'd verify the token format and signature (e.g., using JWT)
8 | //if (!token || token !== "MY_SECRET_TOKEN") {
9 | // return res.status(401).json({ error: "Unauthorized" });
10 | //}
11 |
12 | // if ADMIN_MODE is not true, then return unauthorized
13 | if (!config.ADMIN_MODE) {
14 | return res.status(401).json({ error: "Unauthorized" });
15 | }
16 |
17 | // If authentication passed, move on to the next middleware or route handler
18 | next();
19 | }
20 |
21 | module.exports = { authMiddleware };
--------------------------------------------------------------------------------
/_plugins/code-clock/views/custom.css:
--------------------------------------------------------------------------------
1 | .code-line {
2 | font-family: 'Inter', monospace;
3 | font-size: 1.2em;
4 | line-height: 1.6;
5 | color: #2d3748;
6 | background: #f7fafc;
7 | padding: 0.2em 0.4em;
8 | border-radius: 0.2em;
9 | display: inline-block;
10 | margin: 0.2em 0;
11 | }
12 |
13 | .code-line.large-font {
14 | font-size: 2em;
15 | line-height: 1.4;
16 | padding: 0.4em 0.6em;
17 | }
18 |
19 | .content--center {
20 | text-align: left;
21 | padding: 1em;
22 | background: #f8fafc;
23 | border-radius: 0.5em;
24 | margin: 1em 0;
25 | }
26 |
27 | /* Make the underlined time text match the code size */
28 | .code-line .label--underline {
29 | font-size: 1em; /* This will inherit from parent .code-line size */
30 | font-family: inherit;
31 | vertical-align: baseline;
32 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Environment variables
2 | .env
3 | **/.env
4 | .env.local
5 | .env.*
6 | !.env.example
7 |
8 | # Dependencies
9 | node_modules/
10 | **/node_modules/
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | .pnpm-debug.log*
15 |
16 | # Font files
17 | *.ttf
18 | *.otf
19 | *.woff
20 | *.woff2
21 | *.eot
22 | cache/fonts/*
23 | **/fonts/
24 |
25 |
26 | # System files
27 | .DS_Store
28 | **/DS_Store
29 | Thumbs.db
30 | node_modules/.bin/aaaa
31 | .cursorignore
32 | .cursorrules
33 |
34 | # No cached files
35 | **/tmp/*
36 |
37 | # Cache directory
38 | /cache
39 | /docker_cache
40 |
41 | # Cursor IDE files
42 | .cursor/
43 | .cursorrules
44 | .cursorignore
45 |
46 | # GitHub Copilot files
47 | .github/copilot/
48 | .github/.copilot/
49 | .copilot/
50 | .copilot-*
51 | github-copilot/
52 | devices.db
53 | devices.db-journal
--------------------------------------------------------------------------------
/_plugins/NTFY/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: polling
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://stu-workers.stuey.workers.dev/ndjson-to-json?url=https://ntfy.sh/{{ topic_name }}/json?poll=1&since={{ ntfy_since }}
8 | polling_headers: ''
9 | custom_fields:
10 | - keyname: topic_name
11 | field_type: string
12 | name: Topic Name
13 | description: The ntfy.sh topic to subscribe to
14 | placeholder: trmnl-example12345
15 | - keyname: ntfy_since
16 | field_type: string
17 | name: Time Window
18 | description: How far back to look for notifications
19 | placeholder: 1h
20 | - keyname: max_rows
21 | field_type: number
22 | name: Max Rows
23 | description: Maximum number of notifications to display
24 | placeholder: 6
25 | name: Ntfy Alerts
26 | refresh_interval: 60
--------------------------------------------------------------------------------
/_plugins/my-agenda/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: polling
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: "https://d2e7dd7d-stucal-92a1.stuey.workers.dev/?url={{ calendar_url }}&days={{ days }}&timezone={{ trmnl.user.time_zone_iana }}"
8 | polling_headers: "X-API-Key: {{ X_API_Key }}\ncontent-type: application/json"
9 | custom_fields:
10 | - keyname: calendar_url
11 | field_type: string
12 | name: Calendar URL
13 | description: Your iCal calendar URL (e.g. from Google Calendar)
14 | placeholder: https://calendar.google.com/calendar/ical/example%40gmail.com/public/basic.ics
15 | name: My Agenda
16 | refresh_interval: 300
17 |
18 | custom_fields_values:
19 | calendar_url: https://calendar.google.com/calendar/ical/example%40gmail.com/public/basic.ics
20 | X_API_Key: put into .env file
21 | days: 20
--------------------------------------------------------------------------------
/_plugins/wind-speed-direction/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: polling
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://api.open-meteo.com/v1/forecast?latitude={{ latitude }}&longitude={{ longitude }}&timezone={{ trmnl.user.time_zone_iana }}&hourly=wind_speed_10m,wind_direction_10m,wind_gusts_10m&wind_speed_unit=mph&forecast_days=1
8 | polling_headers: ''
9 | custom_fields:
10 | - keyname: latitude
11 | field_type: string
12 | name: Latitude
13 | description: The latitude of your location. e.g. 40.7128
14 | placeholder: 40.7128
15 | - keyname: longitude
16 | field_type: string
17 | name: Longitude
18 | description: The longitude of your location e.g. -74.0060
19 | placeholder: -74.0060
20 | name: Wind Speed & Direction
21 | refresh_interval: 720
22 |
23 | custom_fields_values:
24 | latitude: 40.7128
25 | longitude: -74.0060
--------------------------------------------------------------------------------
/_plugins/wind-speed-direction/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 | {% assign current_hour = "now" | date: "%H" | plus: 0 %}
2 |
3 |
4 |
5 |
6 |
7 |
8 | Time
9 | Wind
10 | Gusts
11 |
12 |
13 |
14 | {% for i in (current_hour..current_hour | plus: 8) %}
15 | {% assign time = hourly.time[i] | date: "%H:%M" %}
16 |
17 | {{ time }}
18 | {{ hourly.wind_speed_10m[i] }} {{ hourly_units.wind_speed_10m }} {{ hourly.wind_direction_10m[i] }}°
19 | {{ hourly.wind_gusts_10m[i] }} {{ hourly_units.wind_gusts_10m }}
20 |
21 | {% endfor %}
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/_plugins/untested-jellyfin-sample-for-rg/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "movies": [
3 | {
4 | "id": "12345",
5 | "title": "The Shawshank Redemption",
6 | "rating": "9.3",
7 | "year": "1994",
8 | "duration": "142 minutes",
9 | "image": "https://example.com/shawshank.jpg"
10 | },
11 | {
12 | "id": "67890",
13 | "title": "The Godfather",
14 | "rating": "9.2",
15 | "year": "1972",
16 | "duration": "175 minutes",
17 | "image": "https://example.com/godfather.jpg"
18 | },
19 | {
20 | "id": "11223",
21 | "title": "Pulp Fiction",
22 | "rating": "8.9",
23 | "year": "1994",
24 | "duration": "154 minutes",
25 | "image": "https://example.com/pulp-fiction.jpg"
26 | },
27 | {
28 | "id": "44556",
29 | "title": "The Dark Knight",
30 | "rating": "9.0",
31 | "year": "2008",
32 | "duration": "152 minutes",
33 | "image": null
34 | }
35 | ]
36 | }
--------------------------------------------------------------------------------
/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "snippets": [
3 | {
4 | "lines": [
5 | "SELECT * ",
6 | "FROM students",
7 | "WHERE id = {*TRMNL*}}"
8 | ]
9 | },
10 | {
11 | "lines": [
12 | "function _0x0001() ",
13 | "{",
14 | " return Buffer.from('{*TRMNL*}', 'base64');",
15 | "}"
16 | ]
17 | },
18 | {
19 | "lines": [
20 | "Error: 0x{*TRMNL*} at 5f4dcc3b5aa765d61d8327deb882cf99"
21 | ]
22 | },
23 | {
24 | "lines": [
25 | "if(typeof(message)!=='string'){",
26 | "ws.close({*TRMNL*},\"Only JSON-text message allowed\");",
27 | "return;",
28 | "}catch (err){",
29 | "ws.close(\"Only JSON-text message allowed\");",
30 | "return;"
31 | ]
32 | }
33 | ]
34 | }
--------------------------------------------------------------------------------
/_plugins/untested-jellyfin-sample-for-rg/views/full.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% for movie in movies %}
4 |
5 | {% if movie.image %}
6 |
7 | {% endif %}
8 |
9 |
10 |
{{ movie.title }}
11 |
12 | {{ movie.year }} • {{ movie.duration }}
13 |
14 |
15 |
16 |
17 | {{ movie.rating }}
18 | ★
19 |
20 |
21 | {% endfor %}
22 |
23 |
24 |
25 |
Unwatched Movies
26 |
Sorted by rating
27 |
28 |
--------------------------------------------------------------------------------
/_plugins/currency-exchange/views/full.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ data.AUD.value }}
7 | {{ data.AUD.code }}
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{ data.USD.value }}
15 | {{ data.USD.code }}
16 |
17 |
18 |
19 |
20 |
21 |
22 | Currency Exchange Rates
23 | {{ meta.last_updated_at }}
24 |
--------------------------------------------------------------------------------
/_plugins/currency-exchange/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ data.AUD.value }}
7 | {{ data.AUD.code }}
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{ data.USD.value }}
15 | {{ data.USD.code }}
16 |
17 |
18 |
19 |
20 |
21 |
22 | Currency Exchange Rates
23 | {{ meta.last_updated_at }}
24 |
--------------------------------------------------------------------------------
/_plugins/NTFY/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% assign max_rows = trmnl.plugin_settings.custom_fields_values.max_rows | default: 5 %}
5 | {% assign reversed_data = data | reverse %}
6 | {% assign flex = true %}
7 |
8 | {% for alert in reversed_data limit: max_rows %}
9 |
10 |
11 |
12 | {{ alert.time | date: "%-d%b %I:%M %p" }}
13 | {{ alert.message }}
14 |
15 |
16 |
17 | {% endfor %}
18 |
19 |
20 |
21 |
NTFY Alerts
23 |
Topic: {{ trmnl.plugin_settings.custom_fields_values.topic_name }}
24 |
25 |
--------------------------------------------------------------------------------
/_plugins/NTFY/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% assign max_rows = trmnl.plugin_settings.custom_fields_values.max_rows | default: 5 %}
5 | {% assign reversed_data = data | reverse %}
6 | {% assign flex = true %}
7 |
8 | {% for alert in reversed_data limit: max_rows %}
9 |
10 |
11 |
12 | {{ alert.time | date: "%-d%b %I:%M %p" }}
13 | {{ alert.message }}
14 |
15 |
16 |
17 | {% endfor %}
18 |
19 |
20 |
21 |
22 |
NTFY Alerts
24 |
Topic: {{ trmnl.plugin_settings.custom_fields_values.topic_name }}
25 |
--------------------------------------------------------------------------------
/_plugins/currency-exchange/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ data.AUD.value }}
7 | {{ data.AUD.code }}
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{ data.USD.value }}
15 | {{ data.USD.code }}
16 |
17 |
18 |
19 |
20 |
21 |
22 | Currency Exchange Rates
23 | {{ meta.last_updated_at }}
24 |
--------------------------------------------------------------------------------
/_plugins/currency-exchange/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ data.AUD.value }}
7 | {{ data.AUD.code }}
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{ data.USD.value }}
15 | {{ data.USD.code }}
16 |
17 |
18 |
19 |
20 |
21 |
22 | Currency Exchange Rates
23 | {{ meta.last_updated_at }}
24 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "node",
9 | "request": "launch",
10 | "name": "Launch Program",
11 | "skipFiles": [
12 | "/**"
13 | ],
14 | "program": "${workspaceFolder}/app.js",
15 | "env": {
16 | "USE_ADMIN": "true",
17 | "DEBUG": "true",
18 | "ADMIN_MODE": "true",
19 | "PLUGINS_PATH": "${workspaceFolder}/_plugins",
20 | "ENABLE_IMAGE_GENERATION": "true",
21 | "PORT": "3000",
22 | "USE_CACHE": "false"
23 | },
24 | "console": "integratedTerminal",
25 | "internalConsoleOptions": "neverOpen"
26 | }
27 | ]
28 | }
--------------------------------------------------------------------------------
/_plugins/NTFY/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% assign max_rows = trmnl.plugin_settings.custom_fields_values.max_rows | default: 5 %}
5 | {% assign reversed_data = data | reverse %}
6 | {% assign flex = true %}
7 |
8 | {% for alert in reversed_data limit: max_rows %}
9 |
10 |
11 |
12 | {{ alert.time | date: "%-d%b %I:%M %p" }}
13 | {{ alert.message }}
14 |
15 |
16 |
17 | {% endfor %}
18 |
19 |
20 |
21 |
22 |
NTFY Alerts
24 |
Topic: {{ trmnl.plugin_settings.custom_fields_values.topic_name }}
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Build and Push Docker Image
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | workflow_dispatch:
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout Code
14 | uses: actions/checkout@v2
15 |
16 | - name: Set up Docker Buildx
17 | uses: docker/setup-buildx-action@v1
18 |
19 | - name: Log in to Docker Hub
20 | uses: docker/login-action@v1
21 | with:
22 | username: ${{ secrets.DOCKER_USERNAME }}
23 | password: ${{ secrets.DOCKER_TOKEN }}
24 |
25 | - name: Get Version
26 | id: package-version
27 | run: |
28 | echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
29 |
30 | - name: Build and Push
31 | uses: docker/build-push-action@v2
32 | with:
33 | push: true
34 | tags: |
35 | stegg/${{ github.event.repository.name }}:${{ env.VERSION }}
36 | stegg/${{ github.event.repository.name }}:latest
37 | platforms: linux/amd64,linux/arm64
--------------------------------------------------------------------------------
/dev-docs/table.example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Header 1
6 | Header 2
7 | Header 3
8 |
9 |
10 |
11 |
12 | Row 1, Cell 1
13 | Row 1, Cell 2
14 | Row 1, Cell 3
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | Header 1
24 | Header 2
25 | Header 3
26 |
27 |
28 |
29 |
30 | Row 1, Cell 1
31 | Row 1, Cell 2
32 | Row 1, Cell 3
33 |
34 |
35 |
--------------------------------------------------------------------------------
/_plugins/epl-fixtures/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: polling
3 | no_screen_padding: 'yes'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://raw.githubusercontent.com/openfootball/football.json/refs/heads/master/2024-25/en.1.json
8 | polling_headers: 'content-type: application/json'
9 | custom_fields:
10 | - keyname: myteam
11 | field_type: select
12 | options:
13 | - None
14 | - AFC Bournemouth
15 | - Arsenal FC
16 | - Aston Villa FC
17 | - Brentford FC
18 | - Brighton & Hove Albion FC
19 | - Chelsea FC
20 | - Crystal Palace FC
21 | - Everton FC
22 | - Fulham FC
23 | - Ipswich Town FC
24 | - Leicester City FC
25 | - Liverpool FC
26 | - Manchester City FC
27 | - Manchester United FC
28 | - Newcastle United FC
29 | - Nottingham Forest FC
30 | - Southampton FC
31 | - Tottenham Hotspur FC
32 | - West Ham United FC
33 | - Wolverhampton Wanderers FC
34 | default: None
35 | name: My Team
36 | description: Choose a team to highlight
37 | name: EPL Fixtures
38 | description: Shows English Premier League fixtures and results from the open football.json dataset. Displays match dates, teams, and scores for the current season.
39 | refresh_interval: 43200
40 |
41 | custom_fields_values:
42 | myteam: "brighton_&_hove_albion_fc"
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)
2 |
3 | Copyright (c) 2025 Stu Eggerton
4 |
5 | This work is licensed under the Creative Commons Attribution-NonCommercial 4.0 International License.
6 |
7 | To view a copy of this license, visit:
8 | http://creativecommons.org/licenses/by-nc/4.0/
9 |
10 | You are free to:
11 | * Share — copy and redistribute the material in any medium or format
12 | * Adapt — remix, transform, and build upon the material
13 |
14 | Under the following terms:
15 | * Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made.
16 | * NonCommercial — You may not use the material for commercial purposes.
17 |
18 | No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
19 |
20 | Notices:
21 | You do not have to comply with the license for elements of the material in the public domain or where your use is permitted by an applicable exception or limitation.
22 |
23 | No warranties are given. The license may not give you all of the permissions necessary for your intended use. For example, other rights such as publicity, privacy, or moral rights may limit how you use the material.
--------------------------------------------------------------------------------
/_plugins/epl-my-team/settings.yml:
--------------------------------------------------------------------------------
1 | ---
2 | strategy: polling
3 | no_screen_padding: 'no'
4 | dark_mode: 'no'
5 | static_data: ''
6 | polling_verb: get
7 | polling_url: https://raw.githubusercontent.com/openfootball/football.json/refs/heads/master/2024-25/en.1.json
8 | polling_headers: 'content-type: application/json'
9 | name: EPL My Team
10 | description: Displays Manchester United's fixtures and results from the open football.json dataset. Shows past results and upcoming matches for the current season.
11 | refresh_interval: 43200
12 |
13 | custom_fields:
14 | - keyname: myteam
15 | field_type: select
16 | options:
17 | - None
18 | - AFC Bournemouth
19 | - Arsenal FC
20 | - Aston Villa FC
21 | - Brentford FC
22 | - Brighton & Hove Albion FC
23 | - Chelsea FC
24 | - Crystal Palace FC
25 | - Everton FC
26 | - Fulham FC
27 | - Ipswich Town FC
28 | - Leicester City FC
29 | - Liverpool FC
30 | - Manchester City FC
31 | - Manchester United FC
32 | - Newcastle United FC
33 | - Nottingham Forest FC
34 | - Southampton FC
35 | - Tottenham Hotspur FC
36 | - West Ham United FC
37 | - Wolverhampton Wanderers FC
38 | default: None
39 | name: My Team
40 | description: Choose a team to highlight
41 |
42 | custom_fields_values:
43 | myteam: "brighton_&_hove_albion_fc"
--------------------------------------------------------------------------------
/_plugins/NTFY/views/full.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% assign max_rows = trmnl.plugin_settings.custom_fields_values.max_rows | default: 5 %}
5 | {% assign reversed_data = data | reverse %}
6 | {% assign flex = true %}
7 |
8 | {% if alerts.size > 0 %}
9 | {% for alert in reversed_data limit: max_rows %}
10 |
11 |
12 |
13 | {{ alert.time | date: "%-d%b %I:%M %p" }}
14 | {{ alert.message }}
15 |
16 |
17 |
18 | {% endfor %}
19 | {% else %}
20 |
21 | NO alerts found
22 | Check your topic and try again
23 |
24 |
25 | {% endif %}
26 |
27 |
28 |
29 |
30 |
31 |
NTFY Alerts
33 |
Topic: {{ data[0].topic | truncate: 8}}
34 |
35 |
--------------------------------------------------------------------------------
/_plugins/wind-speed-direction/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 | {% assign lat = trmnl.plugin_settings.custom_fields_values.latitude | default: 41.7128 %}
2 | {% assign long = trmnl.plugin_settings.custom_fields_values.longitude | default: -74.0060 %}
3 |
4 | {% assign start_time_hour = 8 %}
5 | {% assign end_time_hour = 11 %}
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Time
14 | Speed
15 | Gusts
16 |
17 |
18 |
19 | {% for i in (start_time_hour..end_time_hour) %}
20 | {% assign time = hourly.time[i] | date: "%H:%M" %}
21 |
22 | {{ time }}
23 | {{ hourly.wind_speed_10m[i] }}
24 | {{ hourly.wind_gusts_10m[i] }}
25 |
26 | {% endfor %}
27 |
28 |
29 |
30 |
31 |
32 |
33 |
Wind Speed
34 |
Open-Meteo
35 |
--------------------------------------------------------------------------------
/scripts/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Check if port 3000 is in use and capture the details
4 | port_info=$(netstat -an | grep LISTEN | grep 3000)
5 |
6 | if [[ -n "$port_info" ]]; then
7 | echo "Error: Port 3000 is already in use. Details:"
8 | echo "$port_info"
9 | exit 1
10 | fi
11 |
12 | # Set debug mode
13 | export DEBUG=true
14 |
15 | # Store the original directory
16 | ORIGINAL_DIR=$(pwd)
17 |
18 | # Navigate to the project root
19 | cd "$(dirname "$0")/.."
20 |
21 | # Check if Node.js is installed
22 | if ! command -v node &> /dev/null
23 | then
24 | echo "Node.js is not installed. Please install Node.js (version 14 or higher) and try again."
25 | exit 1
26 | fi
27 |
28 | # Check if npm is installed
29 | if ! command -v npm &> /dev/null
30 | then
31 | echo "npm is not installed. Please install npm and try again."
32 | exit 1
33 | fi
34 |
35 | # Install dependencies if node_modules doesn't exist
36 | if [ ! -d "node_modules" ]; then
37 | echo "Installing dependencies..."
38 | npm install
39 | if [ $? -ne 0 ]; then
40 | echo "Failed to install dependencies. Please check your internet connection and try again."
41 | exit 1
42 | fi
43 | fi
44 |
45 | # Start the development server with the original directory as PLUGINS_PATH
46 | echo "Starting TRMNL Plugin Tester..."
47 | echo "Access the tool at: http://localhost:3000"
48 | PLUGINS_PATH="$ORIGINAL_DIR/_plugins" ADMIN_MODE=true npm run dev
49 |
50 | # Start the server
51 | node app.js
--------------------------------------------------------------------------------
/_plugins/wind-speed-direction/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 | {% assign lat = trmnl.plugin_settings.custom_fields_values.latitude | default: 41.7128 %}
2 | {% assign long = trmnl.plugin_settings.custom_fields_values.longitude | default: -74.0060 %}
3 | {% assign tz = trmnl.user.time_zone_iana | default: "America/New_York" %}
4 |
5 | {% assign start_time_hour = 8 %}
6 | {% assign end_time_hour = 15 %} # Reduced hours for smaller view
7 |
8 |
9 |
10 |
Wind Conditions for {{ lat }}, {{ long }}
11 |
Timezone: {{ tz }}
12 |
13 |
14 |
15 |
16 |
17 |
18 | Time
19 | Speed
20 | Dir
21 | Gusts
22 |
23 |
24 |
25 | {% for i in (start_time_hour..end_time_hour) %}
26 | {% assign time = hourly.time[i] | date: "%H:%M" %}
27 |
28 | {{ time }}
29 | {{ hourly.wind_speed_10m[i] }}
30 | {{ hourly.wind_direction_10m[i] }}°
31 | {{ hourly.wind_gusts_10m[i] }}
32 |
33 | {% endfor %}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
Wind Speed & Direction
41 |
Data from Open-Meteo
42 |
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
Other Sensors
11 |
12 |
13 | {% for entity in entities %}
14 | {% if entity.device_class != "temperature" %}
15 |
16 | {{ entity.friendly_name }}
17 | {% if entity.attributes.unit_of_measurement %}
18 | {{ entity.value }}{{ entity.attributes.unit_of_measurement }}
19 | {% else %}
20 | {{ entity.state }}{{ entity.unit_of_measurement }}
21 | {% endif %}
22 |
23 | {% endif %}
24 | {% endfor %}
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | Home Assistant
35 | Other Sensors
36 |
37 |
--------------------------------------------------------------------------------
/_plugins/trmnl-broadcast/views/full.liquid:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 | {% if text.size < 20 %}
11 | {{ text }}
12 | {% elsif text.size < 40 %}
13 | {{ text }}
14 | {% elsif text.size < 70 %}
15 | {{ text }}
16 | {% elsif text.size < 120 %}
17 | {{ text }}
18 | {% elsif text.size < 200 %}
19 | {{ text }}
20 | {% else %}
21 | {{ text }}
22 | {% endif %}
23 |
24 |
25 |
26 | TRMNL Broadcast
27 |
28 | {% if trmnl.device.percent_charged > 60 %}
29 |
30 | {% elsif trmnl.device.percent_charged > 40 %}
31 |
32 | {% else %}
33 |
34 | {% endif %}
35 |
--------------------------------------------------------------------------------
/_plugins/trmnl-broadcast/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 | {% if text.size < 5 %}
11 | {{ text }}
12 | {% elsif text.size < 10 %}
13 | {{ text }}
14 | {% elsif text.size < 15 %}
15 | {{ text }}
16 | {% elsif text.size < 20 %}
17 | {{ text }}
18 | {% elsif text.size < 25 %}
19 | {{ text }}
20 | {% else %}
21 | {{ text }}
22 | {% endif %}
23 |
24 |
25 |
26 | TRMNL Broadcast
27 |
28 | {% if trmnl.device.percent_charged > 60 %}
29 |
30 | {% elsif trmnl.device.percent_charged > 40 %}
31 |
32 | {% else %}
33 |
34 | {% endif %}
35 |
--------------------------------------------------------------------------------
/_plugins/trmnl-broadcast/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 | {% if text.size < 15 %}
11 | {{ text }}
12 | {% elsif text.size < 30 %}
13 | {{ text }}
14 | {% elsif text.size < 55 %}
15 | {{ text }}
16 | {% elsif text.size < 90 %}
17 | {{ text }}
18 | {% elsif text.size < 120 %}
19 | {{ text }}
20 | {% else %}
21 | {{ text }}
22 | {% endif %}
23 |
24 |
25 |
26 | TRMNL Broadcast
27 |
28 | {% if trmnl.device.percent_charged > 60 %}
29 |
30 | {% elsif trmnl.device.percent_charged > 40 %}
31 |
32 | {% else %}
33 |
34 | {% endif %}
35 |
--------------------------------------------------------------------------------
/_plugins/trmnl-broadcast/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 | {% if text.size < 15 %}
11 | {{ text }}
12 | {% elsif text.size < 30 %}
13 | {{ text }}
14 | {% elsif text.size < 55 %}
15 | {{ text }}
16 | {% elsif text.size < 90 %}
17 | {{ text }}
18 | {% elsif text.size < 120 %}
19 | {{ text }}
20 | {% else %}
21 | {{ text }}
22 | {% endif %}
23 |
24 |
25 |
26 | TRMNL Broadcast
27 |
28 | {% if trmnl.device.percent_charged > 60 %}
29 |
30 | {% elsif trmnl.device.percent_charged > 40 %}
31 |
32 | {% else %}
33 |
34 | {% endif %}
35 |
--------------------------------------------------------------------------------
/scripts/docker-run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Check if port 3000 is in use and capture the details
4 | port_info=$(netstat -an | grep LISTEN | grep 3000)
5 |
6 | if [[ -n "$port_info" ]]; then
7 | echo "Error: Port 3000 is already in use. Details:"
8 | echo "$port_info"
9 | exit 1
10 | fi
11 |
12 | # Determine the host architecture
13 | ARCH=$(uname -m)
14 | case $ARCH in
15 | arm64|aarch64)
16 | PLATFORM="linux/arm64"
17 | ;;
18 | x86_64|amd64)
19 | PLATFORM="linux/amd64"
20 | ;;
21 | *)
22 | echo "Unsupported architecture: $ARCH"
23 | exit 1
24 | ;;
25 | esac
26 |
27 | # Build the image using the shell script
28 | echo "Building for platform: $PLATFORM"
29 |
30 | # Build the image for the detected platform
31 | # Check if no-cache parameter was passed
32 | if [[ "$1" == "no-cache" ]]; then
33 | echo "Building with no-cache"
34 | docker build --platform $PLATFORM \
35 | --no-cache \
36 | -t trmnl-plugin-tester:latest \
37 | --build-arg BUILDPLATFORM=$PLATFORM .
38 | else
39 | echo "Building with cache (add no-cache if you want to force a rebuild)"
40 | docker build --platform $PLATFORM \
41 | -t trmnl-plugin-tester:latest \
42 | --build-arg BUILDPLATFORM=$PLATFORM .
43 | fi
44 |
45 | # Run the container with the correct platform
46 | echo "Running container for platform: $PLATFORM"
47 |
48 | docker run --platform $PLATFORM \
49 | -p 3000:3000 \
50 | -v "$(pwd)/_plugins:/app/_plugins" \
51 | -v "$(pwd)/cache:/data/cache" \
52 | -e DEBUG_MODE=true \
53 | -e ENABLE_IMAGE_GENERATION=true \
54 | trmnl-plugin-tester:latest
--------------------------------------------------------------------------------
/_plugins/wind-speed-direction/views/full.liquid:
--------------------------------------------------------------------------------
1 | {% assign lat = latitude | default: "UNKNOWN" %}
2 | {% assign long = longitude | default: "UNKNOWN" %}
3 | {% assign tz = trmnl.user.time_zone_iana | default: "UNKNOWN" %}
4 |
5 | {% assign start_time_hour = 8 %}
6 | {% assign end_time_hour = 19 %}
7 |
8 | {% assign current_time = "now" | date: "%Y-%m-%d %H:00" %}
9 | {% assign end_time = "now" | date: "%Y-%m-%d" | append: " 23:00" %}
10 |
11 |
12 |
13 |
Wind Conditions for
14 |
{{ lat }}, {{ long }}
15 |
Timezone: {{ tz }}
16 |
17 |
18 |
19 |
20 |
21 |
22 | Time
23 | Wind Speed ({{ hourly_units.wind_speed_10m }})
24 | Direction ({{ hourly_units.wind_direction_10m }})
25 | Gusts ({{ hourly_units.wind_gusts_10m }})
26 |
27 |
28 |
29 | {% for i in (start_time_hour..end_time_hour) %}
30 | {% assign time = hourly.time[i] | date: "%H:%M" %}
31 |
32 | {{ time }}
33 | {{ hourly.wind_speed_10m[i] }}
34 | {{ hourly.wind_direction_10m[i] }}°
35 | {{ hourly.wind_gusts_10m[i] }}
36 |
37 | {% endfor %}
38 |
39 |
40 |
41 |
42 |
43 |
44 |
Wind Speed & Direction
45 |
Data from Open-Meteo for {{ trmnl.user.first_name }}
46 |
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
Temperature
15 |
16 |
17 | {% for entity in entities %}
18 | {% if entity.device_class == "temperature" %}
19 |
20 | {{ entity.friendly_name }}
21 | {% if entity.attributes.unit_of_measurement %}
22 | {{ entity.value }}{{ entity.attributes.unit_of_measurement }}
23 | {% else %}
24 | {{ entity.state }}{{ entity.unit_of_measurement }}
25 | {% endif %}
26 |
27 | {% endif %}
28 | {% endfor %}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | Home Assistant
39 | Temperature Sensors
40 |
41 |
--------------------------------------------------------------------------------
/scripts/download-cached-cdn-files.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Check if required commands are available
5 | if ! command -v wget &> /dev/null; then
6 | echo "Error: wget is not installed. Please install wget:"
7 | echo "For macOS: brew install wget"
8 | echo "For Ubuntu/Debian: sudo apt-get install wget"
9 | echo "For CentOS/RHEL: sudo yum install wget"
10 | exit 1
11 | fi
12 |
13 | if ! command -v pandoc &> /dev/null; then
14 | echo "Error: pandoc is not installed. Please install pandoc:"
15 | echo "For macOS: brew install pandoc"
16 | echo "For Ubuntu/Debian: sudo apt-get install pandoc"
17 | echo "For CentOS/RHEL: sudo yum install pandoc"
18 | echo "For Windows: choco install pandoc"
19 | exit 1
20 | fi
21 |
22 | # Set the folder name
23 | FOLDER_NAME="design-system/cdn-copy"
24 |
25 | # Create the folder if it doesn't exist
26 | mkdir -p "$FOLDER_NAME"
27 |
28 | # Download framework files
29 | echo "Downloading framework doc files..."
30 | FRAMEWORK_DIR="$FOLDER_NAME/framework"
31 | mkdir -p "$FRAMEWORK_DIR"
32 |
33 | wget --recursive \
34 | --no-clobber \
35 | --page-requisites \
36 | --html-extension \
37 | --convert-links \
38 | --domains usetrmnl.com \
39 | --no-parent \
40 | --directory-prefix="$FRAMEWORK_DIR" \
41 | "https://usetrmnl.com/framework/"
42 |
43 | echo "✓ Successfully downloaded framework files"
44 |
45 | # Convert HTML files to Markdown and delete original HTML files
46 | find "$FRAMEWORK_DIR" -name "*.html" | while read -r html_file; do
47 | md_file="${html_file%.html}.md"
48 | pandoc "$html_file" -f html -t gfm -o "$md_file"
49 | echo "Converted $html_file to $md_file"
50 | rm "$html_file"
51 | done
52 |
53 | echo "Done! Framework documentation files have been saved to $FRAMEWORK_DIR"
54 |
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
Temperature
15 |
16 |
17 | {% for entity in entities %}
18 | {% if entity.device_class == "temperature" %}
19 |
20 | {{ entity.friendly_name }}
21 | {% if entity.attributes.unit_of_measurement %}
22 | {{ entity.value }}{{ entity.attributes.unit_of_measurement }}
23 | {% else %}
24 | {{ entity.state }}{{ entity.unit_of_measurement }}
25 | {% endif %}
26 |
27 | {% endif %}
28 | {% endfor %}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | Home Assistant
39 | Temperature
40 |
41 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use Alpine for a lightweight, multi-arch base image
2 | FROM --platform=$BUILDPLATFORM node:18-alpine3.15 AS base
3 |
4 | #Note latest alpine does not have a imagemagick6 package
5 | #FROM --platform=$BUILDPLATFORM node:18-alpine AS base
6 |
7 | # write the platform to the log
8 | RUN echo "BUILDPLATFORM: $BUILDPLATFORM"
9 |
10 | # Install necessary packages
11 | RUN apk add --no-cache \
12 | chromium \
13 | nss \
14 | freetype \
15 | harfbuzz \
16 | ca-certificates \
17 | ttf-freefont \
18 | imagemagick6
19 |
20 | # NOTE: ImageMagick6 is required for the plugin to work as 7 creates 1.2MB
21 | # files that cannot be displayed on the TRMNL screen. (maybe timeout downloading
22 | # or maybe too large)
23 |
24 | # Set environment variables for Puppeteer
25 | ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
26 | ENV PUPPETEER_SKIP_DOWNLOAD=true
27 | ENV NODE_ENV=production
28 |
29 | # Set up working directory
30 | WORKDIR /app
31 |
32 | # Copy package files and install dependencies
33 | COPY package*.json ./
34 | RUN npm install --omit=dev
35 |
36 | # Copy the rest of the application
37 | COPY . .
38 |
39 | # Create cache directory and public directory
40 | RUN mkdir -p /data/cache /app/public /tmp
41 |
42 | # Expose the default port
43 | EXPOSE 3000
44 |
45 | # Build timestamp
46 | RUN date -u +'%Y-%m-%d %H:%M:%S UTC' > /app/public/build.txt
47 |
48 | # Set environment variables
49 | ENV CACHE_PATH=/data/cache
50 | ENV USE_CACHE=false
51 | ENV DEBUG_MODE=false
52 | ENV MAX_REQUESTS_PER_5_MIN=400
53 |
54 | # Disable image generation in Docker by default
55 | ENV ENABLE_IMAGE_GENERATION=false
56 |
57 | # ImageMagick binary path
58 | ENV IMAGE_MAGICK_BIN=/usr/bin/convert-6
59 |
60 | # Persistent cache volume
61 | VOLUME /data/cache
62 |
63 | # Start the application
64 | CMD ["npm", "start"]
--------------------------------------------------------------------------------
/_plugins/my-agenda/sample.json:
--------------------------------------------------------------------------------
1 | {"agenda":[{"date":"2025-02-04","events":[{"title":"test1","start":"2025-02-04T10:00:00.000Z","end":"2025-02-04T11:15:00.000Z","description":"","isFullDay":false,"crossDay":false}]},{"date":"2025-02-05","events":[{"title":"all day event 1 - v2","start":"2025-02-05","end":"2025-02-05T23:59:59Z","description":"","isFullDay":true,"crossDay":true},{"title":"cross day","start":"2025-02-05T01:30:00.000Z","end":"2025-02-05T23:59:59Z","description":"","isFullDay":false,"crossDay":true},{"title":"test2","start":"2025-02-05T03:15:00.000Z","end":"2025-02-05T05:30:00.000Z","description":"","isFullDay":false,"crossDay":false}]},{"date":"2025-02-06","events":[{"title":"all day event 1 - v2","start":"2025-02-06T00:00:00Z","end":"2025-02-06","description":"","isFullDay":true,"crossDay":true},{"title":"cross day","start":"2025-02-06T00:00:00Z","end":"2025-02-06T03:30:00.000Z","description":"","isFullDay":false,"crossDay":true},{"title":"this is a random item i added for an example","start":"2025-02-06T01:30:00.000Z","end":"2025-02-06T02:30:00.000Z","description":"","isFullDay":false,"crossDay":false},{"title":"test7","start":"2025-02-06T03:00:00.000Z","end":"2025-02-06T04:00:00.000Z","description":"","isFullDay":false,"crossDay":false},{"title":"test9","start":"2025-02-06T04:00:00.000Z","end":"2025-02-06T05:00:00.000Z","description":"","isFullDay":false,"crossDay":false},{"title":"test10","start":"2025-02-06T05:00:00.000Z","end":"2025-02-06T10:00:00.000Z","description":"","isFullDay":false,"crossDay":false}]},{"date":"2025-02-07","events":[{"title":"11","start":"2025-02-07T03:30:00.000Z","end":"2025-02-07T04:30:00.000Z","description":"","isFullDay":false,"crossDay":false},{"title":"walk dogs and take shopping in random long away town far far away","start":"2025-02-07T06:30:00.000Z","end":"2025-02-07T07:30:00.000Z","description":"","isFullDay":false,"crossDay":false}]}]}
--------------------------------------------------------------------------------
/dev-docs/design-system-demo-full.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
Motivational Quote
26 |
“I love inside jokes. I hope to be a part of one
27 | someday.”
28 |
Michael Scott
29 |
30 |
31 |
32 |
33 |
34 |
35 |
Plugin Title
36 |
Instance Title
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/_plugins/NTFY/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": [
3 | {
4 | "id": "9ZkZ0NZctK8V",
5 | "time": 1739156127,
6 | "expires": 1739199327,
7 | "event": "message",
8 | "topic": "trmnl-example12345",
9 | "message": "Test of long text in the box to show on the screen. This is a test of long text in the box to show on the screen. This is a test of long text in the box to show on the screen."
10 | },
11 | {
12 | "id": "YSdtLlDmYt3Q",
13 | "time": 1739156135,
14 | "expires": 1739199335,
15 | "event": "message",
16 | "topic": "trmnl-example12345",
17 | "message": "backups have completed"
18 | },
19 | {
20 | "id": "mRulTGyHjc5q",
21 | "time": 1739156167,
22 | "expires": 1739199367,
23 | "event": "message",
24 | "topic": "trmnl-example12345",
25 | "message": "The battery is low on the blinds"
26 | },
27 | {
28 | "id": "ySRiBQXShn5Q",
29 | "time": 1739156180,
30 | "expires": 1739199380,
31 | "event": "message",
32 | "topic": "trmnl-example12345",
33 | "message": "holiday in 10 days"
34 | },
35 | {
36 | "id": "D4fNin7VGzS9",
37 | "time": 1739163254,
38 | "expires": 1739206454,
39 | "event": "message",
40 | "topic": "trmnl-example12345",
41 | "message": "🙂❤️ test of emoji"
42 | },
43 | {
44 | "id": "iMOnxxpaMleG",
45 | "time": 1739171936,
46 | "expires": 1739215136,
47 | "event": "message",
48 | "topic": "CyiIsZ6gYOLPrzXJCyiIsZ6gYOLPrzXJCyiIsZ6gYOLPrzXJCyiIsZ6gYOLPrzXJ",
49 | "title": "☏Phone Mum",
50 | "message": "Reminder to phone your mother",
51 | "priority": 4,
52 | "tags": [
53 | "tag1",
54 | "tag2"
55 | ],
56 | "click": "https://example.com"
57 | }
58 | ]
59 | }
--------------------------------------------------------------------------------
/public/display-preview.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | TRMNL Display Preview
5 |
47 |
48 |
49 |
50 |
TRMNL Display Preview
51 |
52 | Refresh Preview
53 |
54 |
55 |
56 |
57 |
75 |
76 |
--------------------------------------------------------------------------------
/serve-plugin.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # What does this script do?
4 | # 1. Find all directories containing config.toml
5 | # 2. Display a menu of plugins
6 | # 3. Get user selection
7 | # 4. Serve the selected plugin using trmnlp if it exists, otherwise use Docker
8 |
9 | # Find all directories containing config.toml
10 | plugins=()
11 | while IFS= read -r dir; do
12 | plugins+=("$(basename "$(dirname "$dir")")")
13 | done < <(find . -name "config.toml" -type f)
14 |
15 | # Exit if no plugins found
16 | if [ ${#plugins[@]} -eq 0 ]; then
17 | echo "No plugins found (no config.toml files in subdirectories)"
18 | exit 1
19 | fi
20 |
21 | # Display menu
22 | echo "Available plugins:"
23 | for i in "${!plugins[@]}"; do
24 | echo "$((i+1)). ${plugins[$i]}"
25 | done
26 |
27 | # Get user selection
28 | echo
29 | echo -n "Select plugin (1-${#plugins[@]}): "
30 | read selection
31 |
32 | # Validate input
33 | if ! [[ "$selection" =~ ^[0-9]+$ ]] || [ "$selection" -lt 1 ] || [ "$selection" -gt "${#plugins[@]}" ]; then
34 | echo "Invalid selection"
35 | exit 1
36 | fi
37 |
38 | # Get selected plugin
39 | selected_plugin="${plugins[$((selection-1))]}"
40 | plugin_path="$(pwd)/$selected_plugin"
41 |
42 | # Check if rbenv trmnlp exists
43 | TRMNLP_PATH="/Users/$(whoami)/.rbenv/shims/trmnlp"
44 |
45 | if [ -f "$TRMNLP_PATH" ]; then
46 | # Use rbenv version if it exists
47 | echo "Using local trmnlp installation..."
48 | "$TRMNLP_PATH" serve "$plugin_path"
49 | else
50 | # Fallback to Docker
51 | echo "Local trmnlp not found, using Docker..."
52 |
53 | # Function to cleanup Docker container on script exit
54 | cleanup() {
55 | echo
56 | echo "Shutting down Docker container..."
57 | docker stop trmnlp 2>/dev/null
58 | docker rm trmnlp 2>/dev/null
59 | exit
60 | }
61 |
62 | # Set up trap for cleanup
63 | trap cleanup INT TERM
64 |
65 | echo "Starting plugin: $selected_plugin"
66 | echo "Plugin path: $plugin_path"
67 | echo
68 | echo "Docker command that will be executed:"
69 | echo "docker run \\"
70 | echo " -p 4567:4567 \\"
71 | echo " -v \"$plugin_path:/plugin\" \\"
72 | echo " schrockwell/trmnlp"
73 | echo
74 | echo "Press Enter to execute, Ctrl+C to cancel"
75 | read
76 |
77 | # Run docker
78 | docker run \
79 | --name trmnlp \
80 | -p 4567:4567 \
81 | -v "$plugin_path:/plugin" \
82 | schrockwell/trmnlp
83 |
84 | # Clean up container after exit
85 | cleanup
86 | fi
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/README.md:
--------------------------------------------------------------------------------
1 | # Home Assistant TRMNL Plugin
2 |
3 | Display your Home Assistant sensor data in TRMNL. This plugin shows temperature and other sensors in a clean, organized interface.
4 |
5 | - Uses Home Assistant [shell_command](https://www.home-assistant.io/integrations/shell_command/) to send data to TRMNL.
6 | - Uses a [Home Assistant Label](https://www.home-assistant.io/docs/organizing/labels/) `TRMNL` to identify the entities to send to TRMNL via the webhook.
7 |
8 | 
9 |
10 |
11 | ## Prerequisites
12 | - A Home Assistant instance (Only tested with version 2025.1)
13 | - HACS installed
14 | - A TRMNL account
15 | - A TRMNL device
16 |
17 | ## Private Plugin Setup Instructions
18 |
19 | 1. Download the ZIP file from [github.com/gitstua/trmnl-plugin-dev/tree/main/_plugins/home-assistant-trmnl/releases](https://github.com/gitstua/trmnl-plugin-dev/tree/main/_plugins/home-assistant-trmnl/releases)
20 | 2. Navigate to [usetrmnl.com/plugin_settings?keyname=private_plugin](https://usetrmnl.com/plugin_settings?keyname=private_plugin)
21 | 3. Select IMPORT and specify the ZIP file
22 | 4. Select SAVE
23 | 5. Note the WebHook URL
24 |
25 | ## Home Assistant Setup Instructions (with HACS)
26 | The data is sent from Home Assistant to TRMNL via a webhook.
27 | 1. In Home Assistant, go to HACS > ... > Custom repositories
28 | 
29 | 2. Add the custom repository `https://github.com/gitstua/trmnl-sensor-push`
30 | 3. Select DOWNLOAD
31 | 4. Restart Home Assistant
32 | 5. In Home Assistant, go to Settings > Devices & Services and select ADD INTEGRATION
33 | 6. Select **TRMNL Entity Push** and specify your WebHook URL from the TRMNL private plugin
34 |
35 | ## Label Your Entities in Home Assistant
36 |
37 | 1. In Home Assistant, go to Settings > Devices & Services
38 | 2. Select "Entities"
39 | 3. Click the checkbox icon next to filters to enable selection mode
40 | 
41 |
42 | 4. Select the entities you want to display in TRMNL
43 | 5. Click "Add Label" in the top right
44 | 6. Create a new label called "TRMNL"
45 | 
46 |
47 | You may need to Restart Home Assistant and Force refresh in the plugin page to see the new entities.
48 |
49 | ## Troubleshooting
50 | If you don't see the data in TRMNL, try the following:
51 |
52 | 1. Check that the automation is running by going to Automation > Edit Automation
53 | 2. Check that the webhook URL is correct
54 | 3. Check that the entities are labeled with "TRMNL"
55 | 4. Go to the Home Assistant logs and check for any errors
56 | 5. Restart Home Assistant with Settings > System > Restart
--------------------------------------------------------------------------------
/_plugins/NTFY/README.md:
--------------------------------------------------------------------------------
1 |
2 | # NTFY Plugin for TRMNL
3 |
4 | ## What is ntfy?
5 |
6 | [ntfy](https://ntfy.sh/) is a lightweight, open-source push notification service that allows you to send messages to mobile devices, desktops, or servers in real-time. It is designed for simplicity and efficiency, making it easy to set up and integrate with your systems to receive notifications as events occur.
7 |
8 | Because anyone can connect to your topic you should use a unique topic name and not use it for sensitive information. If you are technical you can self-host ntfy and use a private topic with username/password.
9 |
10 |
11 |
12 |
13 | ## What is the NTFY Plugin?
14 |
15 | This plugin integrates with the ntfy service to display real-time alerts directly on your dashboard. It fetches notifications (alerts) and renders them using the TRMNL Design System, ensuring a consistent and clean interface across your application.
16 |
17 |
18 |
19 |
20 |
21 |
22 | ### Key Features
23 |
24 | - **Periodic Notifications**: The plugin polls for the latest alerts and displays them as they arrive. Because the TRMNL device is an e-ink display, it will only update periodically conserving battery life.
25 |
26 | **REAL TIME NOTIFICATIONS ARE NOT SUPPORTED ON THE TRMNL DEVICE.**
27 |
28 | ## How to use the NTFY plugin
29 |
30 | There are many integrations for ntfy.sh, and you can use any of them to send messages to the topic.
31 |
32 | ### Web
33 |
34 | You can send messages to the topic using the ntfy.sh [web interface](https://ntfy.sh/trmnl-example12345/publish?title=Hello%20World&message=This%20is%20a%20test%20message)
35 |
36 | ### NTFY CLI
37 | You can send messages to the topic using the `ntfy` command line tool.
38 |
39 | ```bash
40 | ntfy publish --topic trmnl-example12345 --title "Hello World" --message "This is a test message"
41 | ```
42 |
43 | ### Curl
44 | ```bash
45 | curl -X POST https://ntfy.sh/trmnl-example12345/publish?title=Hello%20World&message=This%20is%20a%20test%20message
46 | ```
47 |
48 | ### Home Assistant
49 | You can [send messages to the topic from Home Assistant](https://docs.ntfy.sh/examples/#home-assistant) in the configuration.yaml file.
50 |
51 |
52 | ### Others
53 | There are many other integrations for ntfy.sh, and you can use any of them to send messages to the topic.
54 |
55 | See the [ntfy.sh examples](https://docs.ntfy.sh/examples/) for more information.
56 |
57 |
58 |
59 |
60 | This plugin provides a simple and effective way to monitor and display ntfy alerts, making it easier for you to stay updated with real-time notifications.
61 |
62 | Happy notifying!
63 |
--------------------------------------------------------------------------------
/_plugins/epl-fixtures/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | Date
51 | Fixture
52 |
53 |
54 |
55 | {% assign today = "now" | date: "%Y-%m-%d" %}
56 | {% assign counter = 0 %}
57 | {% assign upcoming_matches = matches | sort: "date" %}
58 | {% for match in upcoming_matches %}
59 | {% if match.date > today and counter < 15 %}
60 | {% assign match_date = match.date | date: "%d %b" %}
61 | {% assign home_abbr = match.team1 | slice: 0, 9 %}
62 | {% assign away_abbr = match.team2 | slice: 0, 9 %}
63 | {% assign score = match.score.ft[0] | append: " - " | append: match.score.ft[1] %}
64 |
65 | {{ match_date }}
66 |
67 | {{ home_abbr }}
68 | {{ score }}
69 | {{ away_abbr }}
70 |
71 |
72 | {% assign counter = counter | plus: 1 %}
73 | {% endif %}
74 | {% endfor %}
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/_plugins/epl-fixtures/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | Date
51 | Fixture
52 |
53 |
54 |
55 | {% assign today = "now" | date: "%Y-%m-%d" %}
56 | {% assign counter = 0 %}
57 | {% assign upcoming_matches = matches | sort: "date" %}
58 | {% for match in upcoming_matches %}
59 | {% if match.date > today and counter < 5 %}
60 | {% assign match_date = match.date | date: "%d %b" %}
61 | {% assign home_abbr = match.team1 | slice: 0, 9 %}
62 | {% assign away_abbr = match.team2 | slice: 0, 9 %}
63 | {% assign score = match.score.ft[0] | append: " - " | append: match.score.ft[1] %}
64 |
65 | {{ match_date }}
66 |
67 | {{ home_abbr }}
68 | {{ score }}
69 | {{ away_abbr }}
70 |
71 |
72 | {% assign counter = counter | plus: 1 %}
73 | {% endif %}
74 | {% endfor %}
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/SPECIFICATION.md:
--------------------------------------------------------------------------------
1 | # TRMNL Plugin Tester Specification
2 |
3 | ## Overview
4 | A development tool for testing TRMNL plugins before deployment to the useTRMNL.com platform. The application allows previewing and testing plugins locally or in Docker containers.
5 |
6 | ## Core Requirements
7 |
8 | ### Plugin Management
9 | - Support for single and multi-plugin modes
10 | - Plugin discovery from filesystem
11 | - Plugin settings via YAML configuration
12 | - Support for custom fields and tokens in plugin settings
13 |
14 | ### Preview Features
15 | - Preview plugins in various layouts (full, half-horizontal, half-vertical, quadrant)
16 | - Support for static data, polling, and webhook strategies
17 | - Live data fetching with rate limiting
18 | - Template rendering using LiquidJS
19 |
20 | ### Image Generation
21 | - Generate BMP images for TRMNL e-ink displays
22 | - Use Puppeteer for screenshot capture
23 | - Use ImageMagick for image conversion to BMP format
24 | - Support for different display sizes and layouts
25 | - Image specifications:
26 | - Resolution: 800x480 pixels (standard TRMNL display)
27 | - Color depth: 1-bit (black and white) for e-ink compatibility
28 | - Format: BMP with specific headers for e-ink displays
29 | - Dithering: Floyd-Steinberg algorithm for grayscale conversion
30 | - Support for different layouts (all returned as 800x480 images):
31 | - Full screen (800x480)
32 | - Half-horizontal (800x240)
33 | - Half-vertical (400x480)
34 | - Quadrant (400x240)
35 | - Viewport configuration for accurate rendering
36 | - Caching mechanism to reduce processing load
37 |
38 | ### API Support (BYOS Server)
39 | - REST API for plugin management
40 | - Support for authentication/authorization
41 | - Rate limiting to prevent abuse
42 | - Device management API endpoints
43 | - BYOS (Bring Your Own Server) endpoints:
44 | - `/api/setup`: Device registration endpoint that accepts MAC addresses and returns configuration
45 | - `/api/display`: Returns plugin display configuration with image URLs
46 | - `/display`: Generates BMP images for e-ink displays
47 | - `/api/plugins`: Lists available plugins
48 | - `/api/plugins/:pluginId/export`: Exports plugin as ZIP file for TRMNL import
49 | - `/api/layout/:layout` and `/api/layout/:pluginId/:layout`: Returns layout templates
50 | - `/api/plugin-settings/:pluginId`: Returns plugin settings in YAML format
51 |
52 | ### Deployment
53 | - Docker support with multi-architecture builds (x86, ARM)
54 | - Fly.io deployment configuration
55 | - GitHub Actions for CI/CD
56 | - Environment variable configuration
57 |
58 | ### Data Storage
59 | - SQLite database for device management
60 | - Filesystem storage for plugins and cache
61 | - Support for environment variables and .env files
62 |
63 | ## Technical Stack
64 | - Node.js with Express
65 | - LiquidJS for templating
66 | - Puppeteer for browser automation
67 | - ImageMagick for image processing
68 | - SQLite for lightweight database
69 | - Docker for containerization
70 | - GitHub Actions for CI/CD
71 |
72 | ## Deployment Options
73 | - Local development with Node.js
74 | - Docker container deployment
75 | - Fly.io cloud deployment
--------------------------------------------------------------------------------
/_plugins/wind-speed-direction/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "latitude": -23.5,
3 | "longitude": -46.5,
4 | "generationtime_ms": 0.026702880859375,
5 | "utc_offset_seconds": -10800,
6 | "timezone": "America/Sao_Paulo",
7 | "timezone_abbreviation": "GMT-3",
8 | "elevation": 745.0,
9 | "hourly_units": {
10 | "time": "iso8601",
11 | "wind_speed_10m": "mp/h",
12 | "wind_direction_10m": "°",
13 | "wind_gusts_10m": "mp/h"
14 | },
15 | "hourly": {
16 | "time": [
17 | "2025-02-08T00:00",
18 | "2025-02-08T01:00",
19 | "2025-02-08T02:00",
20 | "2025-02-08T03:00",
21 | "2025-02-08T04:00",
22 | "2025-02-08T05:00",
23 | "2025-02-08T06:00",
24 | "2025-02-08T07:00",
25 | "2025-02-08T08:00",
26 | "2025-02-08T09:00",
27 | "2025-02-08T10:00",
28 | "2025-02-08T11:00",
29 | "2025-02-08T12:00",
30 | "2025-02-08T13:00",
31 | "2025-02-08T14:00",
32 | "2025-02-08T15:00",
33 | "2025-02-08T16:00",
34 | "2025-02-08T17:00",
35 | "2025-02-08T18:00",
36 | "2025-02-08T19:00",
37 | "2025-02-08T20:00",
38 | "2025-02-08T21:00",
39 | "2025-02-08T22:00",
40 | "2025-02-08T23:00"
41 | ],
42 | "wind_speed_10m": [
43 | 2.0,
44 | 2.3,
45 | 1.9,
46 | 1.7,
47 | 1.5,
48 | 1.8,
49 | 1.6,
50 | 1.9,
51 | 3.4,
52 | 4.7,
53 | 5.5,
54 | 6.1,
55 | 6.1,
56 | 6.8,
57 | 9.3,
58 | 9.4,
59 | 9.0,
60 | 9.3,
61 | 8.3,
62 | 7.0,
63 | 5.9,
64 | 4.1,
65 | 4.3,
66 | 3.7
67 | ],
68 | "wind_direction_10m": [
69 | 174,
70 | 169,
71 | 159,
72 | 157,
73 | 153,
74 | 150,
75 | 135,
76 | 126,
77 | 122,
78 | 109,
79 | 104,
80 | 96,
81 | 96,
82 | 128,
83 | 145,
84 | 152,
85 | 157,
86 | 163,
87 | 149,
88 | 143,
89 | 133,
90 | 131,
91 | 129,
92 | 128
93 | ],
94 | "wind_gusts_10m": [
95 | 4.5,
96 | 5.4,
97 | 5.4,
98 | 4.5,
99 | 4.0,
100 | 4.3,
101 | 4.7,
102 | 5.4,
103 | 8.9,
104 | 12.5,
105 | 14.8,
106 | 16.1,
107 | 16.6,
108 | 17.7,
109 | 22.8,
110 | 23.5,
111 | 23.3,
112 | 22.4,
113 | 22.4,
114 | 20.1,
115 | 16.6,
116 | 13.6,
117 | 10.3,
118 | 10.3
119 | ]
120 | }
121 | }
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "entities":
3 | [
4 | {
5 | "id": "sensor.bathroom_temperature",
6 | "area": "bathroom",
7 | "icon": "mdi:thermometer",
8 | "name": "Bathroom Temperature",
9 | "state": "22.5",
10 | "state_class": "measurement",
11 | "device_class": "temperature",
12 | "friendly_name": "Bathroom Temperature",
13 | "unit_of_measurement": "°C"
14 | },
15 | {
16 | "id": "sensor.bedroom_temperature",
17 | "area": "bedroom",
18 | "icon": "mdi:thermometer",
19 | "name": "Bedroom Temperature",
20 | "state": "21.0",
21 | "state_class": "measurement",
22 | "device_class": "temperature",
23 | "friendly_name": "Bedroom Temperature",
24 | "unit_of_measurement": "°C"
25 | },
26 | {
27 | "id": "sensor.outdoor_temperature",
28 | "area": "outdoor",
29 | "icon": "mdi:thermometer-outdoor",
30 | "name": "Outdoor Temperature",
31 | "state": "18.3",
32 | "state_class": "measurement",
33 | "device_class": "temperature",
34 | "friendly_name": "Outdoor Temperature",
35 | "unit_of_measurement": "°C"
36 | },
37 | {
38 | "id": "sensor.outdoor_humidity",
39 | "area": "outdoor",
40 | "icon": "mdi:water-percent",
41 | "name": "Outdoor Humidity",
42 | "state": "65.0",
43 | "state_class": "measurement",
44 | "device_class": "humidity",
45 | "friendly_name": "Outdoor Humidity",
46 | "unit_of_measurement": "%"
47 | },
48 | {
49 | "id": "sensor.living_room_temperature",
50 | "area": "living_room",
51 | "icon": "mdi:thermometer",
52 | "name": "Living Room Temperature",
53 | "state": "23.0",
54 | "state_class": "measurement",
55 | "device_class": "temperature",
56 | "friendly_name": "Living Room Temperature",
57 | "unit_of_measurement": "°C"
58 | },
59 | {
60 | "id": "sensor.kitchen_temperature",
61 | "area": "kitchen",
62 | "icon": "mdi:thermometer",
63 | "name": "Kitchen Temperature",
64 | "state": "24.5",
65 | "state_class": "measurement",
66 | "device_class": "temperature",
67 | "friendly_name": "Kitchen Temperature",
68 | "unit_of_measurement": "°C"
69 | },
70 | {
71 | "id": "sensor.office_temperature",
72 | "area": "office",
73 | "icon": "mdi:thermometer",
74 | "name": "Office Temperature",
75 | "state": "22.0",
76 | "state_class": "measurement",
77 | "device_class": "temperature",
78 | "friendly_name": "Office Temperature",
79 | "unit_of_measurement": "°C"
80 | },
81 | {
82 | "id": "sensor.garage_temperature",
83 | "area": "garage",
84 | "icon": "mdi:thermometer",
85 | "name": "Garage Temperature",
86 | "state": "20.5",
87 | "state_class": "measurement",
88 | "device_class": "temperature",
89 | "friendly_name": "Garage Temperature",
90 | "unit_of_measurement": "°C"
91 | }
92 | ]
93 | }
--------------------------------------------------------------------------------
/_plugins/epl-fixtures/views/full.liquid:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Date
26 | Time
27 | Home
28 | Away
29 |
30 |
31 |
32 |
33 | {% assign today = "now" | date: "%Y-%m-%d" %}
34 | {% assign counter = 0 %}
35 | {% assign upcoming_matches = matches | sort: "date" %}
36 |
37 | {% assign myteam = trmnl.plugin_settings.custom_fields_values.myteam %}
38 |
39 | {% for match in upcoming_matches %}
40 | {% if match.date > today and counter < 9 %} {% assign formatted_date=match.date | date: "%-d %b" %}
42 |
43 | {{ formatted_date }}
44 | {{ match.time }}
45 |
46 |
47 | {% assign normalized_team1 = match.team1 | downcase | replace: " ", "_" %}
48 | {% if myteam == normalized_team1 %}
49 | {{ match.team1 }}
50 | {% else %}
51 | {{ match.team1 }}
52 | {% endif %}
53 |
54 |
55 |
56 |
57 | {% assign normalized_team2 = match.team2 | downcase | replace: " ", "_" %}
58 | {% if myteam == normalized_team2 %}
59 | {{ match.team2 }}
60 | {% else %}
61 | {{ match.team2 }}
62 | {% endif %}
63 |
64 |
65 |
66 |
67 | {% if match.score.ft %}
68 | {{ match.score.ft[0] }} - {{ match.score.ft[1] }}
69 | {% endif %}
70 |
71 |
72 |
73 | {% assign counter = counter | plus: 1 %}
74 | {% endif %}
75 | {% endfor %}
76 |
77 |
78 |
79 |
80 |
81 |
82 |
Premier League
83 |
Upcoming Fixtures
84 |
--------------------------------------------------------------------------------
/_plugins/home-assistant-trmnl/views/full.liquid:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
Temperature Sensors
11 |
12 |
13 |
14 | Location
15 | Value
16 |
17 |
18 |
19 | {% for entity in entities %}
20 | {% if entity.device_class == "temperature" %}
21 |
22 | {{ entity.friendly_name }}
23 |
24 | {% if entity.attributes.unit_of_measurement %}
25 | {{ entity.value }}{{ entity.attributes.unit_of_measurement }}
26 | {% else %}
27 | {{ entity.state }} {{ entity.unit_of_measurement }}
28 | {% endif %}
29 |
30 |
31 |
32 |
33 | {% endif %}
34 | {% endfor %}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
Other Sensors
43 |
44 |
45 |
46 | Sensor
47 | Value
48 |
49 |
50 |
51 | {% for entity in entities %}
52 | {% if entity.device_class != "temperature" %}
53 |
54 | {{ entity.friendly_name }}
55 |
56 | {% if entity.attributes.unit_of_measurement %}
57 | {{ entity.value }}{{ entity.attributes.unit_of_measurement }}
58 | {% else %}
59 | {{ entity.state }}{{ entity.unit_of_measurement }}
60 | {% endif %}
61 |
62 | {% endif %}
63 | {% endfor %}
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | Home Assistant
74 | Entities labelled TRMNL
75 |
76 |
--------------------------------------------------------------------------------
/_plugins/code-clock/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | {% assign current_time = 'now' | date: '%H%M' %}
60 | {% assign random_index = "now" | date: "%s" | modulo: snippets.size %}
61 | {% assign snippet = snippets[random_index] %}
62 |
63 | {% for line in snippet.lines %}
64 | {% if line contains '{**TRMNL**}' %}
65 | {% assign parts = line | split: '{**TRMNL**}' %}
66 |
{{ parts[0] }}{{ current_time }} {{ parts[1] }}
67 | {% else %}
68 |
{{ line }}
69 | {% endif %}
70 | {% unless forloop.last %}
{% endunless %}
71 | {% endfor %}
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | Code Clock
81 | The above time is < now()
82 |
--------------------------------------------------------------------------------
/_plugins/code-clock/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | {% assign current_time = 'now' | date: '%H%M' %}
59 | {% assign random_index = "now" | date: "%s" | modulo: snippets.size %}
60 | {% assign snippet = snippets[random_index] %}
61 |
62 | {% for line in snippet.lines %}
63 | {% if line contains '{**TRMNL**}' %}
64 | {% assign parts = line | split: '{**TRMNL**}' %}
65 |
{{ parts[0] }}{{ current_time }} {{ parts[1] }}
66 | {% else %}
67 |
{{ line }}
68 | {% endif %}
69 | {% unless forloop.last %}
{% endunless %}
70 | {% endfor %}
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | Code Clock
80 | The above time is < now()
81 |
--------------------------------------------------------------------------------
/_plugins/code-clock/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | {% assign current_time = 'now' | date: '%H%M' %}
60 | {% assign random_index = "now" | date: "%s" | modulo: snippets.size %}
61 | {% assign snippet = snippets[random_index] %}
62 |
63 | {% for line in snippet.lines %}
64 | {% if line contains '{**TRMNL**}' %}
65 | {% assign parts = line | split: '{**TRMNL**}' %}
66 |
{{ parts[0] }}{{ current_time }} {{ parts[1] }}
67 | {% else %}
68 |
{{ line }}
69 | {% endif %}
70 | {% unless forloop.last %}
{% endunless %}
71 | {% endfor %}
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | Code Clock
81 | The above time is < now()
82 |
--------------------------------------------------------------------------------
/_plugins/epl-fixtures/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | Match #
35 | Date
36 | Time
37 | Home
38 | Away
39 | Score
40 |
41 |
42 |
43 | {% assign today = "now" | date: "%Y-%m-%d" %}
44 | {% assign counter = 0 %}
45 | {% assign upcoming_matches = matches | sort: "date" %}
46 | {% for match in upcoming_matches %}
47 | {% if match.date > today and counter < 15 %}
48 | {% assign formatted_date = match.date | date: "%-d %b" %}
49 |
50 | {{ forloop.index0 | plus: 1 }}
51 | {{ formatted_date }}
52 | {{ match.time }}
53 |
54 |
55 | {% if match.score.ft[0] > match.score.ft[1] %}
56 | {{ match.team1 }}
57 | {% else %}
58 | {{ match.team1 }}
59 | {% endif %}
60 |
61 |
62 |
63 |
64 | {% if match.score.ft[1] > match.score.ft[0] %}
65 | {{ match.team2 }}
66 | {% else %}
67 | {{ match.team2 }}
68 | {% endif %}
69 |
70 |
71 |
72 |
73 | {% if match.score.ft %}
74 | {{ match.score.ft[0] }} - {{ match.score.ft[1] }}
75 | {% else %}
76 | TBD
77 | {% endif %}
78 |
79 |
80 |
81 | {% assign counter = counter | plus: 1 %}
82 | {% endif %}
83 | {% endfor %}
84 |
85 |
86 |
87 |
88 |
--------------------------------------------------------------------------------
/_plugins/code-clock/views/full.liquid:
--------------------------------------------------------------------------------
1 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | {% assign current_time = 'now' | date: '%H%M' %}
71 | {% assign random_index = "now" | date: "%s" | modulo: snippets.size %}
72 | {% assign snippet = snippets[random_index] %}
73 |
74 | {% for line in snippet.lines %}
75 | {% if line contains '{**TRMNL**}' %}
76 | {% assign parts = line | split: '{**TRMNL**}' %}
77 |
{{ parts[0] }}{{ current_time }} {{ parts[1] }}
78 | {% else %}
79 |
{{ line }}
80 | {% endif %}
81 | {% unless forloop.last %}
{% endunless %}
82 | {% endfor %}
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | Code Clock
92 | The above time is < now()
93 |
94 |
--------------------------------------------------------------------------------
/admin/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Device Management Admin
6 |
7 |
8 |
9 |
Device Management
10 |
16 |
17 |
18 |
19 |
20 | MAC
21 | API Key
22 | Description
23 | Actions
24 |
25 |
26 |
27 |
28 |
29 |
30 |
Admin UI Title Bar
31 |
32 |
116 |
117 |
--------------------------------------------------------------------------------
/admin/index.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const sqlite3 = require('sqlite3').verbose();
3 | const router = express.Router();
4 | const path = require('path');
5 | const config = require('../config');
6 |
7 | // Add middleware to parse JSON bodies
8 | router.use(express.json());
9 |
10 | if (!config.ADMIN_MODE) {
11 | console.log('Admin mode is not enabled');
12 | module.exports = router; // Export empty router if admin mode disabled
13 | return;
14 | }
15 |
16 | // Initialize and open the SQLite database (creates the file if it doesn't exist)
17 | const db = new sqlite3.Database('./devices.db', (err) => {
18 | console.log('📀 Connecting to SQLite database at full path:', path.join(__dirname, '../devices.db'));
19 | if (err) {
20 | console.error('📀 Could not connect to SQLite database', err);
21 | } else {
22 | console.log('📀 Connected to SQLite database.');
23 | }
24 |
25 | console.log('📀 Database path:', db.filename);
26 | });
27 |
28 | // Create the devices table if it doesn't exist already.
29 | db.run(
30 | `CREATE TABLE IF NOT EXISTS devices (
31 | mac TEXT PRIMARY KEY,
32 | api_key TEXT,
33 | description TEXT
34 | )`,
35 | (err) => {
36 | if (err) {
37 | console.error('Could not create table', err);
38 | } else {
39 | console.log('Table "devices" is ready.');
40 | }
41 | }
42 | );
43 |
44 | // GET /api/admin/devices
45 | router.get('/devices', (req, res) => {
46 | db.all('SELECT * FROM devices', (err, rows) => {
47 | if (err) {
48 | console.error('Error retrieving devices', err);
49 | return res.status(500).json({ error: 'Internal Server Error' });
50 | }
51 | res.json({ devices: rows });
52 | });
53 | });
54 |
55 | // POST /api/admin/devices
56 | router.post('/devices', (req, res) => {
57 | const { mac, api_key, description } = req.body;
58 |
59 | if (!mac || !api_key || !description) {
60 | return res.status(400).json({ error: 'Missing required fields: mac, api_key, description' });
61 | }
62 |
63 | const stmt = `INSERT INTO devices (mac, api_key, description) VALUES (?, ?, ?)`;
64 | db.run(stmt, [mac, api_key, description], function(err) {
65 | if (err) {
66 | console.error('Error adding device', err);
67 | return res.status(500).json({ error: 'Internal Server Error' });
68 | }
69 | res.status(201).json({
70 | message: 'Device added successfully',
71 | device: { mac, api_key, description }
72 | });
73 | });
74 | });
75 |
76 | // PUT /api/admin/devices/:mac
77 | router.put('/devices/:mac', (req, res) => {
78 | const { mac } = req.params;
79 | const { api_key, description } = req.body;
80 |
81 | if (!api_key || !description) {
82 | return res.status(400).json({ error: 'Missing required fields: api_key, description' });
83 | }
84 |
85 | const stmt = `UPDATE devices SET api_key = ?, description = ? WHERE mac = ?`;
86 | db.run(stmt, [api_key, description, mac], function(err) {
87 | if (err) {
88 | console.error('Error updating device', err);
89 | return res.status(500).json({ error: 'Internal Server Error' });
90 | }
91 | if (this.changes === 0) {
92 | return res.status(404).json({ error: 'Device not found' });
93 | }
94 | res.json({
95 | message: 'Device updated successfully',
96 | device: { mac, api_key, description }
97 | });
98 | });
99 | });
100 |
101 | // DELETE /api/admin/devices/:mac
102 | router.delete('/devices/:mac', (req, res) => {
103 | const { mac } = req.params;
104 | const stmt = `DELETE FROM devices WHERE mac = ?`;
105 | db.run(stmt, [mac], function(err) {
106 | if (err) {
107 | console.error('Error deleting device', err);
108 | return res.status(500).json({ error: 'Internal Server Error' });
109 | }
110 | if (this.changes === 0) {
111 | return res.status(404).json({ error: 'Device not found' });
112 | }
113 | res.json({ message: 'Device deleted successfully' });
114 | });
115 | });
116 |
117 | // Admin UI route
118 | router.get('/', (req, res) => {
119 | res.sendFile(path.join(__dirname, 'index.html'));
120 | });
121 |
122 | // Export the router
123 | module.exports = router;
--------------------------------------------------------------------------------
/_plugins/my-agenda/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% assign items_per_column = 5 %}
4 | {% assign items_before_wrap = items_per_column | minus: 1 %}
5 | {% assign max_cols = 2 %}
6 |
7 |
8 | {% assign item_count = 0 %}
9 | {% assign current_col = 1 %}
10 | {% for day in agenda %}
11 | {% if item_count == items_before_wrap %}
12 |
13 | {% assign item_count = items_per_column %}
14 | {% endif %}
15 |
16 | {% if item_count == items_per_column %}
17 | {% if current_col < max_cols %}
18 |
19 | {% assign current_col = current_col | plus: 1 %}
20 | {% assign item_count = 0 %}
21 | {% else %}
22 | {% break %}
23 | {% endif %}
24 | {% endif %}
25 |
26 | {% if item_count < items_before_wrap %}
27 |
28 |
29 | {{ day.date | date: "%A" }}, {{ day.date | date: "%-d%b" }}
30 |
31 | {% assign item_count = item_count | plus: 1 %}
32 |
33 | {% for event in day.events %}
34 | {% if item_count == items_per_column %}
35 |
36 | {% if current_col < max_cols %}
37 |
38 | {% assign current_col = current_col | plus: 1 %}
39 | {% assign item_count = 0 %}
40 |
41 | {% else %}
42 | {% break %}
43 | {% endif %}
44 | {% endif %}
45 |
46 | {% if item_count < items_per_column %}
47 |
48 |
49 | {{ forloop.index }}
50 |
51 |
52 |
53 | {{ event.title }}
54 | {% if event.crossDay %}
55 | →
56 | {% endif %}
57 |
58 |
59 | {% if event.isFullDay %}
60 | All Day
61 | {% else %}
62 | {{ event.start | date: "%I:%M %p" | timezone: timezone }} - {{ event.end | date: "%I:%M %p" | timezone: timezone }}
63 | {% endif %}
64 |
65 |
66 |
67 | {% assign item_count = item_count | plus: 1 %}
68 | {% endif %}
69 | {% endfor %}
70 |
71 | {% endif %}
72 | {% endfor %}
73 |
74 | {% if item_count > 0 and item_count < items_per_column %}
75 | {% assign remaining = items_per_column | minus: item_count %}
76 | {% for i in (1..remaining) %}
77 |
78 | {% endfor %}
79 | {% endif %}
80 |
81 |
82 |
83 |
84 |
85 | My Agenda
86 |
--------------------------------------------------------------------------------
/_plugins/my-agenda/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% assign items_per_column = 5 %}
4 | {% assign items_before_wrap = items_per_column | minus: 1 %}
5 | {% assign max_cols = 2 %}
6 |
7 |
8 | {% assign item_count = 0 %}
9 | {% assign current_col = 1 %}
10 | {% for day in agenda %}
11 | {% if item_count == items_before_wrap %}
12 |
13 | {% assign item_count = items_per_column %}
14 | {% endif %}
15 |
16 | {% if item_count == items_per_column %}
17 | {% if current_col < max_cols %}
18 |
19 | {% assign current_col = current_col | plus: 1 %}
20 | {% assign item_count = 0 %}
21 | {% else %}
22 | {% break %}
23 | {% endif %}
24 | {% endif %}
25 |
26 | {% if item_count < items_before_wrap %}
27 |
28 |
29 | {{ day.date | date: "%A" }}, {{ day.date | date: "%-d%b" }}
30 |
31 | {% assign item_count = item_count | plus: 1 %}
32 |
33 | {% for event in day.events %}
34 | {% if item_count == items_per_column %}
35 |
36 | {% if current_col < max_cols %}
37 |
38 | {% assign current_col = current_col | plus: 1 %}
39 | {% assign item_count = 0 %}
40 |
41 | {% else %}
42 | {% break %}
43 | {% endif %}
44 | {% endif %}
45 |
46 | {% if item_count < items_per_column %}
47 |
48 |
49 | {{ forloop.index }}
50 |
51 |
52 |
53 | {{ event.title }}
54 | {% if event.crossDay %}
55 | →
56 | {% endif %}
57 |
58 |
59 | {% if event.isFullDay %}
60 | All Day
61 | {% else %}
62 | {{ event.start | date: "%I:%M %p" | timezone: timezone }} - {{ event.end | date: "%I:%M %p" | timezone: timezone }}
63 | {% endif %}
64 |
65 |
66 |
67 | {% assign item_count = item_count | plus: 1 %}
68 | {% endif %}
69 | {% endfor %}
70 |
71 | {% endif %}
72 | {% endfor %}
73 |
74 | {% if item_count > 0 and item_count < items_per_column %}
75 | {% assign remaining = items_per_column | minus: item_count %}
76 | {% for i in (1..remaining) %}
77 |
78 | {% endfor %}
79 | {% endif %}
80 |
81 |
82 |
83 |
84 |
85 | My Agenda
86 |
--------------------------------------------------------------------------------
/_plugins/my-agenda/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% assign items_per_column = 10 %}
4 | {% assign items_before_wrap = items_per_column | minus: 1 %}
5 | {% assign max_cols = 2 %}
6 |
7 |
8 | {% assign item_count = 0 %}
9 | {% assign current_col = 1 %}
10 | {% for day in agenda %}
11 | {% if item_count == items_before_wrap %}
12 |
13 | {% assign item_count = items_per_column %}
14 | {% endif %}
15 |
16 | {% if item_count == items_per_column %}
17 | {% if current_col < max_cols %}
18 |
19 | {% assign current_col = current_col | plus: 1 %}
20 | {% assign item_count = 0 %}
21 | {% else %}
22 | {% break %}
23 | {% endif %}
24 | {% endif %}
25 |
26 | {% if item_count < items_before_wrap %}
27 |
28 |
29 | {{ day.date | date: "%A" }}, {{ day.date | date: "%-d%b" }}
30 |
31 | {% assign item_count = item_count | plus: 1 %}
32 |
33 | {% for event in day.events %}
34 | {% if item_count == items_per_column %}
35 |
36 | {% if current_col < max_cols %}
37 |
38 | {% assign current_col = current_col | plus: 1 %}
39 | {% assign item_count = 0 %}
40 |
41 | {% else %}
42 | {% break %}
43 | {% endif %}
44 | {% endif %}
45 |
46 | {% if item_count < items_per_column %}
47 |
48 |
49 | {{ forloop.index }}
50 |
51 |
52 |
53 | {{ event.title }}
54 | {% if event.crossDay %}
55 | →
56 | {% endif %}
57 |
58 |
59 | {% if event.isFullDay %}
60 | All Day
61 | {% else %}
62 | {{ event.start | date: "%I:%M %p" | timezone: timezone }} - {{ event.end | date: "%I:%M %p" | timezone: timezone }}
63 | {% endif %}
64 |
65 |
66 |
67 | {% assign item_count = item_count | plus: 1 %}
68 | {% endif %}
69 | {% endfor %}
70 |
71 | {% endif %}
72 | {% endfor %}
73 |
74 | {% if item_count > 0 and item_count < items_per_column %}
75 | {% assign remaining = items_per_column | minus: item_count %}
76 | {% for i in (1..remaining) %}
77 |
78 | {% endfor %}
79 | {% endif %}
80 |
81 |
82 |
83 |
84 |
85 | My Agenda
86 |
--------------------------------------------------------------------------------
/_plugins/epl-my-team/views/half_vertical.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Home
11 | Score
12 | Away
13 |
14 |
15 |
16 | {% assign counter = 0 %}
17 | {% assign all_matches = matches | sort: "date" | reverse %}
18 |
19 | {% for match in all_matches %}
20 | {% if counter < 7 and match.score.ft and match.score.ft[0] != null %}
21 | {% if match.team1 == "Manchester United FC" or match.team2 == "Manchester United FC" %}
22 | {% assign match_date = match.date | date: "%-d %b" %}
23 |
24 |
25 |
26 | {% if match.score.ft[0] > match.score.ft[1] %}
27 | {{ match.team1 }}
28 | {% else %}
29 | {{ match.team1 }}
30 | {% endif %}
31 |
32 |
33 | {{ match.score.ft[0] }}-{{ match.score.ft[1] }}
34 |
35 |
36 | {% if match.score.ft[1] > match.score.ft[0] %}
37 | {{ match.team2 }}
38 | {% else %}
39 | {{ match.team2 }}
40 | {% endif %}
41 |
42 |
43 |
44 | {% assign counter = counter | plus: 1 %}
45 | {% endif %}
46 | {% endif %}
47 | {% endfor %}
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | {% assign counter = 0 %}
58 | {% assign upcoming_matches = matches | sort: "date" %}
59 | {% for match in upcoming_matches %}{% if counter < 7 and match.score.ft == nil %}{% if match.team1 == "Manchester United FC" or match.team2 == "Manchester United FC" %}{% assign match_date = match.date | date: "%-d %b" %}{% if match.team1 == "Manchester United FC" %}{{ match_date }} HOME {{ match.team2 }}{% else %}{{ match_date }} AWAY {{ match.team1 }}{% endif %}{% unless forloop.last %}, {% endunless %}{% assign counter = counter | plus: 1 %}{% endif %}{% endif %}{% endfor %}
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
Manchester United FC
68 |
Results & Fixtures
69 |
70 |
--------------------------------------------------------------------------------
/_plugins/code-clock/sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "snippets": [
3 | {
4 | "lines": [
5 | "SELECT * ",
6 | "FROM student_scores",
7 | "WHERE id = {**TRMNL**}",
8 | "ORDER BY score score DESC"
9 | ]
10 | },
11 | {
12 | "lines": [
13 | "function _0x0001() ",
14 | "{",
15 | " return Buffer.from('{**TRMNL**}', 'base64');",
16 | "}"
17 | ]
18 | },
19 | {
20 | "lines": [
21 | "Error: 0x{**TRMNL**} at 5f4dcc3b5aa765d61d8327deb882cf99"
22 | ]
23 | },
24 | {
25 | "lines": [
26 | "if(typeof(message)!=='string'){",
27 | "ws.close({**TRMNL**},\"Only JSON-text message allowed\");",
28 | "return;",
29 | "}catch (err){",
30 | "ws.close(\"Only JSON-text message allowed\");",
31 | "return;"
32 | ]
33 | },
34 | {
35 | "lines": [
36 | "SELECT * FROM users WHERE id = {**TRMNL**} "
37 | ]
38 | },
39 | {
40 | "lines": [
41 | "console.log((0x{**TRMNL**}).toString(2))"
42 | ]
43 | },
44 | {
45 | "lines": [
46 | "const text = '{**TRMNL**}encodedText';",
47 | "const decoded = atob(text);"
48 | ]
49 | },
50 | {
51 | "lines": [
52 | "import { createHash } from 'crypto';",
53 | "const hash = createHash('sha256').update('{**TRMNL**}data').digest('hex');"
54 | ]
55 | },
56 | {
57 | "lines": [
58 | "at Parser.parseTokens (/Users/stu/code/trmnl-plugins/node_modules/liquidjs/dist/liquid.node.cjs.js:4531:33)",
59 | "at Parser.parse (/Users/stu/code/trmnl-plugins/node_modules/liquidjs/dist/liquid.node.cjs.js:4525:21)",
60 | "at Liquid.parse (/Users/stu/code/trmnl-plugins/node_modules/liquidjs/dist/liquid.node.cjs.js:{**TRMNL**}:28)"
61 | ]
62 | },
63 | {
64 | "lines": [
65 | "if(typeof(message)!=='string'){",
66 | " ws.close({**TRMNL**},\"Only JSON-text message allowed\");",
67 | " return;",
68 | "}"
69 | ]
70 | },
71 | {
72 | "lines": [
73 | "try {",
74 | " const data = JSON.parse('{**TRMNL**}');",
75 | "} catch (err) {",
76 | " console.error('Invalid JSON at line {**TRMNL**}');",
77 | "}"
78 | ]
79 | },
80 | {
81 | "lines": [
82 | "const time = '{**TRMNL**}';"
83 | ]
84 | },
85 | {
86 | "lines": [
87 | "$echo the time was '{**TRMNL**}' "
88 | ]
89 | },
90 | {
91 | "lines": [
92 | "typedef struct DeviceStatusStamp",
93 | "{",
94 | " int8_t wifi_rssi_level;",
95 | " char wifi_status[30];",
96 | " uint32_t refresh_rate;",
97 | " uint32_t time_since_last_sleep;",
98 | " char current_fw_version[10];",
99 | " char special_function[{**TRMNL**}];",
100 | " float battery_voltage;",
101 | " char wakeup_reason[30];",
102 | " uint32_t free_heap_size;",
103 | " ",
104 | " ScreenStatus screen_status;",
105 | "} DeviceStatusStamp;"
106 | ]
107 | },
108 | {
109 | "lines": [
110 | "else if (pin == HIGH && elapsed > {**TRMNL**})",
111 | "{",
112 | " Log_info(\"Button time=%d pin=%d: detected no-action\", elapsed, pin);",
113 | " return NoAction;",
114 | "}"
115 | ]
116 | },
117 | {
118 | "lines": [
119 | "Paint_SelectImage(BlackImage);",
120 | "Paint_Clear(WHITE);",
121 | "Paint_DrawBitMap(image_buffer + {**TRMNL**});"
122 | ]
123 | },
124 | {
125 | "lines": [
126 | "size_t chunkSize = _min({**TRMNL**}, diff);",
127 | "uint16_t res = file.write(in_buffer + bytesWritten, chunkSize);"
128 | ]
129 | },
130 | {
131 | "lines": [
132 | "#define WIFI_MAX_SSID_LENGTH {**TRMNL**}",
133 | "#define WIFI_MAX_PASSWORD_LENGTH 40"
134 | ]
135 | }
136 | ]
137 | }
--------------------------------------------------------------------------------
/_plugins/epl-my-team/views/full.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
Recent Results
8 |
9 |
10 |
11 | Date
12 | Home
13 | Score
14 | Away
15 |
16 |
17 |
18 | {% assign counter = 0 %}
19 | {% assign all_matches = matches | sort: "date" | reverse %}
20 | {% assign myteam = trmnl.plugin_settings.custom_fields_values.myteam %}
21 |
22 | {% for match in all_matches %}
23 | {% if counter < 7 and match.score.ft and match.score.ft[0] != nil %}
24 |
25 | {% assign normalized_team1 = match.team1 | downcase | replace: " ", "_" %}
26 | {% assign normalized_team2 = match.team2 | downcase | replace: " ", "_" %}
27 |
28 | {% if normalized_team1 == myteam or normalized_team2 == myteam %}
29 | {% assign match_date = match.date | date: "%-d %b" %}
30 |
31 | {{ match_date }}
32 |
33 |
34 | {% if match.score.ft[0] > match.score.ft[1] %}
35 | {{ match.team1 }}
36 | {% else %}
37 | {{ match.team1 }}
38 | {% endif %}
39 |
40 |
41 | {{ match.score.ft[0] }} - {{ match.score.ft[1] }}
42 |
43 |
44 | {% if match.score.ft[1] > match.score.ft[0] %}
45 | {{ match.team2 }}
46 | {% else %}
47 | {{ match.team2 }}
48 | {% endif %}
49 |
50 |
51 |
52 | {% assign counter = counter | plus: 1 %}
53 | {% endif %}
54 | {% endif %}
55 | {% endfor %}
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
Upcoming Fixtures
65 |
66 |
67 |
68 | Date
69 | Time
70 | Home
71 | Away
72 |
73 |
74 |
75 | {% assign counter = 0 %}
76 | {% assign upcoming_matches = matches | sort: "date" %}
77 |
78 | {% for match in upcoming_matches %}
79 | {% if counter < 7 and match.score.ft == nil %}
80 |
81 | {% assign normalized_team1 = match.team1 | downcase | replace: " ", "_" %}
82 | {% assign normalized_team2 = match.team2 | downcase | replace: " ", "_" %}
83 |
84 | {% if normalized_team1 == myteam or normalized_team2 == myteam %}
85 | {% assign match_date = match.date | date: "%-d %b" %}
86 |
87 | {{ match_date }}
88 | {{ match.time }}
89 | {{ match.team1 }}
90 | {{ match.team2 }}
91 |
92 | {% assign counter = counter | plus: 1 %}
93 | {% endif %}
94 | {% endif %}
95 | {% endfor %}
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
{{ myteam | replace: "_", " " | upcase }}
105 |
Results & Fixtures
106 |
107 |
--------------------------------------------------------------------------------
/routes/api.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 |
4 | // Import required dependencies
5 | const path = require('path');
6 | const { version } = require('../package.json');
7 | const config = require('../config');
8 | const { getAvailablePlugins } = require('../config');
9 | const fs = require('fs').promises;
10 | const JSZip = require('jszip');
11 | const yaml = require('js-yaml');
12 | const dotenv = require('dotenv');
13 | const toml = require('toml');
14 |
15 | /**
16 | * Mock display API endpoint
17 | * Returns a random plugin display configuration
18 | */
19 | router.get('/display', async (req, res) => {
20 | // generate a random plugin id from the list of plugins
21 | console.log("Display request received");
22 |
23 | // call getAvailablePlugins
24 | const plugins = await getAvailablePlugins();
25 | const pluginId = plugins[Math.floor(Math.random() * plugins.length)].id;
26 | const layout = 'full';
27 | const hostname = req.hostname;
28 |
29 | const image_url = `http://${hostname}:${config.PORT}/display?plugin=${pluginId}&layout=${layout}`;
30 |
31 | res.json({
32 | status: 0,
33 | image_url: image_url,
34 | filename: 'trmnl-display.bmp',
35 | update_firmware: false,
36 | firmware_url: 'https://trmnl.s3.us-east-2.amazonaws.com/path-to-firmware.bin',
37 | refresh_rate: '30',
38 | reset_firmware: false,
39 | special_function: 'sleep'
40 | });
41 | });
42 |
43 | /**
44 | * Version endpoint
45 | * Returns version and feature flags
46 | */
47 | router.get('/version', (req, res) => {
48 | res.json({
49 | version,
50 | imageGenerationEnabled: config.ENABLE_IMAGE_GENERATION
51 | });
52 | });
53 |
54 | /**
55 | * Setup endpoint
56 | * Handles device setup requests and returns configuration
57 | * TODO: use the device id to get the device from the databasexs
58 | */
59 | router.get('/setup', (req, res) => {
60 | // Get MAC address from headers
61 | const deviceId = req.headers['id'];
62 |
63 | console.log("Setup request received for device: ", deviceId);
64 |
65 | // Mock validation - consider XX:XX:XX:XX:XX as valid MAC address
66 | if (true || deviceId === 'XX:XX:XX:XX:XX') {
67 | res.json({
68 | status: 200,
69 | api_key: "8n--JkLmRtWxYzVqNpd3Q", //NOT A REAL API KEY
70 | friendly_id: "1A2B3C",
71 | image_url: "https://usetrmnl.com/images/setup/setup-logo.bmp",
72 | filename: "empty_state",
73 | message: "Welcome to TRMNL BYOS by Stu"
74 | });
75 | } else {
76 | res.json({
77 | status: 404,
78 | api_key: null,
79 | friendly_id: null,
80 | image_url: null,
81 | filename: null
82 | });
83 | }
84 | });
85 |
86 | // Define routes
87 | router.get('/plugins', async (req, res) => {
88 | // ... route handler code ...
89 | });
90 |
91 | /**
92 | * Export endpoint
93 | * This endpoint will generate a zip file containing:
94 | * - The plugin's settings.yml file
95 | * - All Liquid template files from the plugin's "views" directory
96 | *
97 | * The endpoint is available at:
98 | * POST /api/plugins/:pluginId/export
99 | */
100 | router.post('/plugins/:pluginId/export', async (req, res) => {
101 | try {
102 | const { pluginId } = req.params;
103 | const pluginPath = config.getPluginPath(pluginId);
104 |
105 | // Load .env file if it exists
106 | let envConfig = {};
107 | try {
108 | const envPath = path.join(pluginPath, '.env');
109 | envConfig = dotenv.parse(await fs.readFile(envPath));
110 | } catch (err) {
111 | console.warn(`No .env file found for plugin ${pluginId}`);
112 | }
113 |
114 | // Load and copy the settings.yml file directly
115 | const settingsPath = path.join(pluginPath, 'settings.yml');
116 | const settings = yaml.load(await fs.readFile(settingsPath, 'utf-8'));
117 |
118 | // Create a new JSZip instance
119 | const zip = new JSZip();
120 |
121 | // Add settings.yml to the zip
122 | zip.file('settings.yml', await fs.readFile(settingsPath, 'utf8'));
123 |
124 | // Get the views folder contents and add all .liquid files
125 | const viewsDir = path.join(pluginPath, 'views');
126 | const files = await fs.readdir(viewsDir);
127 | for (const file of files) {
128 | if (file.endsWith('.liquid')) {
129 | const content = await fs.readFile(path.join(viewsDir, file), 'utf-8');
130 | zip.file(`${file}`, content);
131 | }
132 | }
133 |
134 | // Generate and send the zip
135 | const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
136 | const epochTime = Date.now();
137 | const filename = `${pluginId}-plugin-${epochTime}.zip`;
138 |
139 | res.setHeader('Content-Type', 'application/zip');
140 | res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
141 | res.send(zipBuffer);
142 | } catch (err) {
143 | console.error('Export failed:', err);
144 | res.status(500).json({ error: 'Export failed', details: err.message });
145 | }
146 | });
147 |
148 | // Export the router
149 | module.exports = router;
--------------------------------------------------------------------------------
/_plugins/epl-my-team/views/quadrant.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Date
11 | Home
12 | Score
13 | Away
14 |
15 |
16 |
17 | {% assign counter = 0 %}
18 | {% assign all_matches = matches | sort: "date" | reverse %}
19 |
20 | {% for match in all_matches %}
21 | {% if counter < 1 and match.score.ft and match.score.ft[0] != null %} {% if
22 | match.team1=="Manchester United FC" or match.team2=="Manchester United FC" %} {% assign
23 | match_date=match.date | date: "%-d %b" %}
24 | {{ match_date }}
25 |
26 |
27 | {% if match.score.ft[0] > match.score.ft[1] %}
28 | {{ match.team1 }}
29 | {% else %}
30 | {{ match.team1 }}
31 | {% endif %}
32 |
33 |
34 | {{ match.score.ft[0] }}-{{ match.score.ft[1]
35 | }}
36 |
37 |
38 | {% if match.score.ft[1] > match.score.ft[0] %}
39 | {{ match.team2 }}
40 | {% else %}
41 | {{ match.team2 }}
42 | {% endif %}
43 |
44 |
45 |
46 | {% assign counter = counter | plus: 1 %}
47 | {% endif %}
48 | {% endif %}
49 | {% endfor %}
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | Date
62 | Time
63 | Home
64 | Away
65 |
66 |
67 |
68 | {% assign counter = 0 %}
69 | {% assign upcoming_matches = matches | sort: "date" %}
70 |
71 | {% for match in upcoming_matches %}
72 | {% if counter < 1 and match.score.ft==nil %} {% if match.team1=="Manchester United FC" or
73 | match.team2=="Manchester United FC" %} {% assign match_date=match.date | date: "%-d %b"
74 | %}
75 | {{ match_date }}
76 | {{ match.time }}
77 | {{ match.team1 }}
78 | {{ match.team2 }}
79 |
80 | {% assign counter = counter | plus: 1 %}
81 | {% endif %}
82 | {% endif %}
83 | {% endfor %}
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
Manchester United FC
93 |
Results & Fixtures
94 |
95 |
--------------------------------------------------------------------------------
/_plugins/epl-my-team/views/half_horizontal.liquid:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
Recent Results
8 |
9 |
10 |
11 | Date
12 | Home
13 | Score
14 | Away
15 |
16 |
17 |
18 | {% assign counter = 0 %}
19 | {% assign all_matches = matches | sort: "date" | reverse %}
20 |
21 | {% for match in all_matches %}
22 | {% if counter < 3 and match.score.ft and match.score.ft[0] != nil %} {% if
23 | match.team1=="Manchester United FC" or match.team2=="Manchester United FC" %} {% assign
24 | match_date=match.date | date: "%-d %b" %}
25 | {{ match_date }}
26 |
27 |
28 | {% if match.score.ft[0] > match.score.ft[1] %}
29 | {{ match.team1 }}
30 | {% else %}
31 | {{ match.team1 }}
32 | {% endif %}
33 |
34 |
35 | {{ match.score.ft[0] }} - {{ match.score.ft[1]
36 | }}
37 |
38 |
39 | {% if match.score.ft[1] > match.score.ft[0] %}
40 | {{ match.team2 }}
41 | {% else %}
42 | {{ match.team2 }}
43 | {% endif %}
44 |
45 |
46 |
47 | {% assign counter = counter | plus: 1 %}
48 | {% endif %}
49 | {% endif %}
50 | {% endfor %}
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
Upcoming Fixtures
60 |
61 |
62 |
63 | Date
64 | Time
65 | Home
66 | Away
67 |
68 |
69 |
70 | {% assign counter = 0 %}
71 | {% assign upcoming_matches = matches | sort: "date" %}
72 |
73 | {% for match in upcoming_matches %}
74 | {% if counter < 3 and match.score.ft==nil %} {% if match.team1=="Manchester United FC" or
75 | match.team2=="Manchester United FC" %} {% assign match_date=match.date | date: "%-d %b"
76 | %}
77 | {{ match_date }}
78 | {{ match.time }}
79 | {{ match.team1 }}
80 | {{ match.team2 }}
81 |
82 | {% assign counter = counter | plus: 1 %}
83 | {% endif %}
84 | {% endif %}
85 | {% endfor %}
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
Manchester United FC
95 |
Results & Fixtures
96 |
97 |
--------------------------------------------------------------------------------
/_plugins/my-agenda/views/full.liquid:
--------------------------------------------------------------------------------
1 | {% assign items_per_column = 9 %}
2 | {% assign items_before_wrap = items_per_column | minus: 1 %}
3 | {% assign max_cols = 3 %}
4 |
5 | {% assign font_size = "large" %}
6 |
7 |
8 |
9 |
10 |
11 | {% assign item_count = 0 %}
12 | {% assign current_col = 1 %}
13 | {% for day in agenda %}
14 | {% if item_count == items_before_wrap %}
15 |
{% comment %}Add dummy item to fill column{% endcomment %}
16 | {% assign item_count = items_per_column %}
17 | {% endif %}
18 |
19 | {% if item_count == items_per_column %}
20 | {% if current_col < max_cols %}
21 |
22 | {% assign current_col = current_col | plus: 1 %}
23 | {% assign item_count = 0 %}
24 | {% else %}
25 | {% break %}
26 | {% endif %}
27 | {% endif %}
28 |
29 | {% comment %}Only start a new day if we have room for header + at least 1 event{% endcomment %}
30 | {% if item_count < items_before_wrap %}
31 |
32 |
33 | {{ day.date | date: "%A" }}, {{ day.date | date: "%-d%b" }}
34 |
35 | {% assign item_count = item_count | plus: 1 %}
36 |
37 | {% for event in day.events %}
38 | {% if item_count == items_per_column %}
39 |
40 | {% if current_col < max_cols %}
41 |
42 | {% assign current_col = current_col | plus: 1 %}
43 | {% assign item_count = 0 %}
44 |
45 | {% else %}
46 | {% break %}
47 | {% endif %}
48 | {% endif %}
49 |
50 | {% if item_count < items_per_column %}
51 |
52 |
53 | {{ forloop.index }}
54 |
55 |
56 |
57 | {{ event.title }}
58 | {% if event.crossDay %}
59 | →
60 | {% endif %}
61 |
62 |
63 | {% if event.isFullDay %}
64 | All Day
65 | {% else %}
66 | {{ event.start | date: "%I:%M %p" | timezone: timezone }} - {{ event.end | date: "%I:%M %p" | timezone: timezone }}
67 | {% endif %}
68 |
69 |
70 |
71 | {% assign item_count = item_count | plus: 1 %}
72 | {% endif %}
73 | {% endfor %}
74 |
75 | {% endif %}
76 | {% endfor %}
77 |
78 | {% comment %}Fill remaining slots in last column{% endcomment %}
79 | {% if item_count > 0 and item_count < items_per_column %}
80 | {% assign remaining = items_per_column | minus: item_count %}
81 | {% for i in (1..remaining) %}
82 |
83 | {% endfor %}
84 | {% endif %}
85 |
86 |
87 |
88 |
89 |
114 |
115 |
116 | My Agenda
117 |
--------------------------------------------------------------------------------
/download-assets.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const https = require('https');
4 | const { promisify } = require('util');
5 | const mkdir = promisify(fs.mkdir);
6 | const writeFile = promisify(fs.writeFile);
7 | const readFile = promisify(fs.readFile);
8 | const stat = promisify(fs.stat);
9 |
10 | const CDN_BASE = 'https://usetrmnl.com';
11 | const CACHE_PATH = process.env.CACHE_PATH || path.join(process.cwd(), 'cache');
12 | const CACHE_FILE = path.join(CACHE_PATH, 'cdn-assets.cache');
13 | const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds
14 | const SOURCE_PATH = path.join(__dirname, 'design-system'); // Path to source files
15 |
16 | // Create cache directory if it doesn't exist
17 | if (!fs.existsSync(CACHE_PATH)) {
18 | fs.mkdirSync(CACHE_PATH, { recursive: true });
19 | }
20 |
21 | // assets to download {localname: remotename}
22 | const assets = {
23 | css: {
24 | 'css/latest/plugins.css': '/css/latest/plugins.css' // Store in same structure as CDN
25 | },
26 | js: {
27 | 'js/latest/plugins.js': '/js/latest/plugins.js' // Store in same structure as CDN
28 | },
29 | fonts: {
30 | 'fonts/NicoClean-Regular.ttf': '/fonts/NicoClean-Regular.ttf',
31 | 'fonts/NicoBold-Regular.ttf': '/fonts/NicoBold-Regular.ttf',
32 | 'fonts/BlockKie.ttf': '/fonts/BlockKie.ttf',
33 | 'fonts/NicoPups-Regular.ttf': '/fonts/NicoPups-Regular.ttf',
34 | 'fonts/inter.css': 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap'
35 | },
36 | images: {
37 | // Border images
38 | 'images/borders/1.png': '/images/borders/1.png',
39 | 'images/borders/2.png': '/images/borders/2.png',
40 | 'images/borders/3.png': '/images/borders/3.png',
41 | 'images/borders/4.png': '/images/borders/4.png',
42 | 'images/borders/5.png': '/images/borders/5.png',
43 | 'images/borders/6.png': '/images/borders/6.png',
44 | 'images/borders/7.png': '/images/borders/7.png',
45 | // Grayscale images
46 | 'images/grayscale/gray-1.png': '/images/grayscale/gray-1.png',
47 | 'images/grayscale/gray-2.png': '/images/grayscale/gray-2.png',
48 | 'images/grayscale/gray-3.png': '/images/grayscale/gray-3.png',
49 | 'images/grayscale/gray-4.png': '/images/grayscale/gray-4.png',
50 | 'images/grayscale/gray-5.png': '/images/grayscale/gray-5.png',
51 | 'images/grayscale/gray-6.png': '/images/grayscale/gray-6.png',
52 | 'images/grayscale/gray-7.png': '/images/grayscale/gray-7.png',
53 | 'images/grayscale/gray-out.png': '/images/grayscale/gray-out.png',
54 | // Layout images
55 | 'images/layout/full--title_bar-v2.png': '/images/layout/full--title_bar-v2.png',
56 | 'images/layout/full--title_bar.png': '/images/layout/full--title_bar.png',
57 | 'images/layout/half_horizontal--title_bar.png': '/images/layout/half_horizontal--title_bar.png',
58 | 'images/layout/half_horizontal.png': '/images/layout/half_horizontal.png',
59 | 'images/layout/half_vertical--title_bar.png': '/images/layout/half_vertical--title_bar.png',
60 | 'images/layout/half_vertical.png': '/images/layout/half_vertical.png',
61 | 'images/layout/quadrant--title_bar.png': '/images/layout/quadrant--title_bar.png',
62 | 'images/layout/quadrant.png': '/images/layout/quadrant.png'
63 | }
64 | };
65 |
66 | async function isCacheValid() {
67 | try {
68 | const cacheContent = await readFile(CACHE_FILE, 'utf8');
69 | const cacheTime = parseInt(cacheContent);
70 | const age = Date.now() - cacheTime;
71 | return age <= CACHE_DURATION;
72 | } catch (error) {
73 | return false;
74 | }
75 | }
76 |
77 | async function updateCacheTimestamp() {
78 | await mkdir(path.dirname(CACHE_FILE), { recursive: true });
79 | await writeFile(CACHE_FILE, Date.now().toString());
80 | }
81 |
82 | async function downloadFile(url, outputPath) {
83 | console.log(`Downloading ${url}...`);
84 | return new Promise((resolve, reject) => {
85 | const protocol = url.startsWith('https') ? https : require('http');
86 | protocol.get(url, (response) => {
87 | if (response.statusCode !== 200) {
88 | reject(new Error(`Failed to download ${url}: ${response.statusCode}`));
89 | return;
90 | }
91 |
92 | const chunks = [];
93 | response.on('data', (chunk) => chunks.push(chunk));
94 | response.on('end', async () => {
95 | const buffer = Buffer.concat(chunks);
96 | await mkdir(path.dirname(outputPath), { recursive: true });
97 | await writeFile(outputPath, buffer);
98 | console.log(`Downloaded ${url} to ${outputPath}`);
99 | resolve();
100 | });
101 | }).on('error', reject);
102 | });
103 | }
104 |
105 | async function extractAndDownloadImages(cssPath) {
106 | try {
107 | const cssContent = await readFile(cssPath, 'utf8');
108 | const urlRegex = /url\(['"]?([^'")\s]+)['"]?\)/g;
109 | let match;
110 |
111 | while ((match = urlRegex.exec(cssContent)) !== null) {
112 | const url = match[1];
113 | // Skip data URLs, fonts, and already downloaded files
114 | if (url.startsWith('data:') ||
115 | url.includes('.ttf') ||
116 | url.includes('.woff')) {
117 | continue;
118 | }
119 |
120 | // Convert relative URLs to absolute
121 | const fullUrl = url.startsWith('http') ? url : `${CDN_BASE}${url}`;
122 |
123 | // Create relative path for the image
124 | const relativePath = url.startsWith('http')
125 | ? new URL(url).pathname
126 | : url;
127 | const outputPath = path.join(CACHE_PATH, relativePath.replace(/^\//, ''));
128 |
129 | await downloadFile(fullUrl, outputPath);
130 | }
131 | } catch (error) {
132 | console.error('Error processing images from CSS:', error);
133 | throw error;
134 | }
135 | }
136 |
137 | async function copyDirectory(src, dest) {
138 | try {
139 | await mkdir(dest, { recursive: true });
140 | const entries = await fs.readdir(src, { withFileTypes: true });
141 |
142 | for (const entry of entries) {
143 | const srcPath = path.join(src, entry.name);
144 | const destPath = path.join(dest, entry.name);
145 |
146 | if (entry.isDirectory()) {
147 | await copyDirectory(srcPath, destPath);
148 | } else {
149 | await fs.copyFile(srcPath, destPath);
150 | console.log(`Copied ${srcPath} to ${destPath}`);
151 | }
152 | }
153 | } catch (error) {
154 | console.warn(`Warning: Could not copy directory ${src}, error:`, error.message);
155 | }
156 | }
157 |
158 | async function downloadAssets() {
159 | try {
160 | // If USE_CACHE is false, skip downloading and just use CDN directly
161 | if (process.env.USE_CACHE !== 'true') {
162 | console.log('💾 Cache disabled, using CDN directly:', CDN_BASE);
163 | return;
164 | }
165 |
166 | console.log('💾 Cache enabled, using directory:', CACHE_PATH);
167 |
168 | // Check cache first
169 | if (await isCacheValid()) {
170 | console.log('💾 Using cached CDN files since less than 10 minutes have passed');
171 | return;
172 | }
173 |
174 | console.log('💾 Cache expired or not found, downloading assets...');
175 |
176 | // Download all assets maintaining CDN structure
177 | for (const [type, files] of Object.entries(assets)) {
178 | for (const [outputFile, urlPath] of Object.entries(files)) {
179 | const url = urlPath.startsWith('http') ? urlPath : `${CDN_BASE}${urlPath}`;
180 | const outputPath = path.join(CACHE_PATH, outputFile);
181 | await downloadFile(url, outputPath);
182 | }
183 | }
184 |
185 | // Update cache timestamp
186 | await updateCacheTimestamp();
187 | console.log('💾 All assets downloaded successfully to cache');
188 | } catch (error) {
189 | console.error('💾 Error handling assets:', error);
190 | process.exit(1);
191 | }
192 | }
193 |
194 | module.exports = downloadAssets;
195 |
196 | // Run directly if called from command line
197 | if (require.main === module) {
198 | downloadAssets();
199 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TRMNL Plugin Tester
2 |
3 | A development tool for testing TRMNL plugins. This project allows you to preview and test plugins locally or in a Docker container before deploying them to the [useTRMNL.com](https://usetrmnl.com) platform.
4 |
5 | ## Features
6 |
7 | - **Plugin Preview**: Preview plugins in various layouts (full, half-horizontal, half-vertical, quadrant).
8 | - Uses standard `settings.yml` format compatible with TRMNL import/export
9 | - Preview plugin content with live data or sample data
10 | - Copy layout templates for different screen sizes
11 | - Export plugins as ZIP files ready for TRMNL import
12 |
13 | - **Local & API Support**: Work with local `sample.json` files or call external APIs for live data.
14 | - **Clipboard Integration**: Copy layouts and API URLs directly to the clipboard for easy pasting into the TRMNL plugin dashboard.
15 | - **Image Generation**: Generate BMP images for TRMNL displays using Puppeteer and ImageMagick.
16 | - **Rate Limiting**: Built-in rate limiting to prevent abuse (default: 400 requests per 5 minutes).
17 | - Basic BYOS features such as
18 | - add/remove device in SQLite database
19 | - `/api/setup` (just mocked to accept any MAC address at present)
20 | - `/api/display` and associated BMP generation
21 | - docker built and [pushed to docker hub](https://hub.docker.com/r/stuartleeks/trmnl-plugin-tester) for x86 and ARM
22 |
23 | ## Live demo
24 | I have a live demo of this project running on [fly.io](https://trmnl-plugins.fly.dev/)
25 |
26 | ## Configuration Options
27 |
28 | The following configuration options are available in `config.js`:
29 |
30 | ### Paths
31 | - **`PLUGINS_PATH`**: Path to the plugins directory (default: `./_plugins`).
32 | - **`CACHE_PATH`**: Path to the cache directory (default: `./cache`). (NOTE: cache may be removed in future and could be broken)
33 |
34 | ### Feature Flags
35 | - **`ENABLE_IMAGE_GENERATION`**: Enable/disable image generation (default: `false`).
36 | - **`DEBUG_MODE`**: Enable debug mode for detailed logging (default: `false`).
37 | - **`ADMIN_MODE`**: Enable admin mode for additional BYOS features (default: `false`).
38 |
39 | ### Rate Limiting
40 | - **`MAX_REQUESTS_PER_5_MIN`**: Maximum number of requests allowed in 5 minutes (default: `400`).
41 |
42 | ### Server Config
43 | - **`PORT`**: Port for the server to listen on (default: `3000`).
44 |
45 | ### Browser Config
46 | - **`BROWSER_LAUNCH_CONFIG`**: Configuration for Puppeteer (e.g., sandbox, GPU, and memory settings).
47 |
48 |
49 | ### Image Generation
50 | - **`IMAGE_MAGICK_SWICTHES`**: ImageMagick options for converting images to BMP (default: `-dither FloydSteinberg -monochrome -depth 1 -strip -compress RLE -define bmp:format=bmp3`).
51 | - **`IMAGE_MAGICK_BIN`**: Path to the ImageMagick binary (default: `convert`).
52 |
53 | ### Derived Paths
54 | - **`FONTS_PATH`**: Path to fonts directory.
55 | - **`CSS_PATH`**: Path to CSS files.
56 | - **`JS_PATH`**: Path to JavaScript files.
57 |
58 | ## Getting Started
59 |
60 | ### Prerequisites
61 | - [Node.js](https://nodejs.org/) installed.
62 | - Git installed on your machine.
63 |
64 | ### Installation
65 | 1. Clone the repository:
66 | ```bash
67 | git clone https://github.com/gitstua/trmnl-plugin-dev.git
68 | cd trmnl-plugin-dev
69 | ```
70 |
71 | 2. Start the development server:
72 | ```bash
73 | ./scripts/run.sh
74 | ```
75 |
76 | This will start the server and provide the URL to open the preview in your browser.
77 |
78 | ## Docker Support
79 |
80 | You can run the TRMNL Plugin Tester using Docker:
81 |
82 | ### Using Docker in Development
83 | To build and run the image, use the following command:
84 | ```bash
85 | ./scripts/run-docker.sh
86 | ```
87 |
88 | ### Using Docker Hub Image
89 | The Docker image is built and pushed to Docker Hub for x86 and ARM architectures. You can pull it directly from [stuartleeks/trmnl-plugin-tester](https://hub.docker.com/r/stuartleeks/trmnl-plugin-tester).
90 |
91 | To run the image, use the following command:
92 | ```bash
93 | docker run -d -p 3000:3000 stuartleeks/trmnl-plugin-tester
94 | ```
95 |
96 | ## Plugin Configuration
97 |
98 | Each plugin requires a `settings.yml` file that defines how it works. This format is compatible with TRMNL's import/export functionality. Here's an example:
99 |
100 | ```yaml
101 | ---
102 | strategy: polling # polling, static, or webhook
103 | no_screen_padding: 'no' # yes/no - removes padding around the screen
104 | dark_mode: 'no' # yes/no - inverts colors for dark mode
105 | polling_verb: get # HTTP verb for polling strategy
106 | polling_url: https://api.example.com/data # URL to fetch data from
107 | polling_headers: 'content-type: application/json' # HTTP headers
108 | name: Example Plugin # Display name of the plugin
109 | description: Plugin description # Shown in plugin gallery
110 | refresh_interval: 3600 # Seconds between updates
111 |
112 | # Optional custom fields that users can configure
113 | custom_fields:
114 | - keyname: apikey # Internal field name
115 | field_type: string # string, number, select
116 | name: API Key # Display name
117 | description: Your API key # Help text
118 | placeholder: your-api-key # Example value
119 | ```
120 |
121 | The plugin should also include:
122 | - `views/` directory with Liquid templates for different layouts
123 | - `sample.json` for testing without live data
124 | - Optional `.env` file for secrets (add to .gitignore)
125 |
126 | ## Example Plugins
127 |
128 | ### Code Clock
129 | - Displays the time of image generation embedded in random code snippets.
130 | - **DATA**: Static snippets with dynamic time insertion.
131 | - **SOURCE**: [_plugins/code-clock](_plugins/code-clock).
132 | 
133 |
134 | ### Home Assistant TRMNL Plugin
135 | - Displays Home Assistant sensor data in TRMNL.
136 | - **DATA**: Home Assistant API.
137 | - **SOURCE**: [_plugins/home-assistant-trmnl](_plugins/home-assistant-trmnl).
138 | 
139 |
140 | ### NTFY Plugin
141 | - Displays periodic alerts from the [ntfy.sh](https://ntfy.sh/) notification service.
142 | - **DATA**: ntfy.sh API.
143 | - **SOURCE**: [_plugins/ntfy](_plugins/ntfy).
144 | 
145 |
146 | ### My Agenda
147 | - Shows upcoming events in an agenda view.
148 | - **DATA**: Custom API for converting ICAL to JSON.
149 | - **SOURCE**: [_plugins/my-agenda](_plugins/my-agenda).
150 | 
151 |
152 | ### Currency Exchange
153 | - Shows the current exchange rate for a currency pair.
154 | - **DATA**: [currencyapi.com](https://currencyapi.com/).
155 | - **SOURCE**: [_plugins/currency-exchange](_plugins/currency-exchange).
156 | 
157 |
158 | ### EPL Fixtures
159 | - Shows upcoming English Premier League fixtures.
160 | - **DATA**: [GitHub repo](https://github.com/openfootball).
161 | - **SOURCE**: [_plugins/epl-fixtures](_plugins/epl-fixtures).
162 | 
163 |
164 | ### Random Fact
165 | - Displays interesting random facts.
166 | - **DATA**: [uselessfacts.jsph.pl](https://uselessfacts.jsph.pl).
167 | - **SOURCE**: [_plugins/random-fact](_plugins/random-fact).
168 | 
169 |
170 | ### Random Joke
171 | - Shows setup and punchline of random jokes.
172 | - **DATA**: [official-joke-api.appspot.com](https://official-joke-api.appspot.com/random_joke).
173 | - **SOURCE**: [_plugins/random-joke](_plugins/random-joke).
174 | 
175 |
176 | ### Wind Speed & Direction
177 | - Displays hourly wind speed, direction, and gusts data.
178 | - **DATA**: Open-Meteo API.
179 | - **SOURCE**: [_plugins/wind-speed-direction](_plugins/wind-speed-direction).
180 | 
181 |
182 | ### TRMNL Broadcast
183 | - Displays custom messages and announcements.
184 | - **DATA**: Static JSON or custom webhook endpoint.
185 | - **SOURCE**: [_plugins/trmnl-broadcast](_plugins/trmnl-broadcast).
186 | 
187 |
188 | ## Credits / Acknowledgements
189 |
190 | This project would not have been possible without:
191 | - The [TRMNL](https://usetrmnl.com/) project.
192 | - [@schrockwell](https://github.com/schrockwell) for the [TRMNL Preview](https://github.com/schrockwell/trmnl_preview).
193 | - [LiquidJS](https://liquidjs.com) for the templating engine.
194 | - [ImageMagick](https://imagemagick.org/) for image conversion.
195 |
--------------------------------------------------------------------------------