├── _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 | Fractal 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 | Fractal 4 |
5 | 6 |
7 | Stu Fractals 8 | Generated Art 9 |
10 |
-------------------------------------------------------------------------------- /_plugins/stu-fractals/views/half_horizontal.liquid: -------------------------------------------------------------------------------- 1 |
2 |
3 | Fractal 4 |
5 | 6 |
7 | Stu Fractals 8 | Generated Art 9 |
10 |
-------------------------------------------------------------------------------- /_plugins/stu-fractals/views/half_vertical.liquid: -------------------------------------------------------------------------------- 1 |
2 |
3 | Fractal 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 | ![Preview - Full](../_plugins/home-assistant-trmnl/preview/full.png) 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 | 9 | 10 | 11 | 12 | 13 | 14 | {% for i in (current_hour..current_hour | plus: 8) %} 15 | {% assign time = hourly.time[i] | date: "%H:%M" %} 16 | 17 | 18 | 19 | 20 | 21 | {% endfor %} 22 | 23 |
TimeWindGusts
{{ time }}{{ hourly.wind_speed_10m[i] }} {{ hourly_units.wind_speed_10m }} {{ hourly.wind_direction_10m[i] }}°{{ hourly.wind_gusts_10m[i] }} {{ hourly_units.wind_gusts_10m }}
24 |
25 |
26 | 27 |
28 |
Wind Forecast
29 |
-------------------------------------------------------------------------------- /_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 | {{ movie.title }} 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 Icon 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 Icon 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 Icon 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 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
Header 1Header 2Header 3
Row 1, Cell 1Row 1, Cell 2Row 1, Cell 3
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
Header 1Header 2Header 3
Row 1, Cell 1Row 1, Cell 2Row 1, Cell 3
-------------------------------------------------------------------------------- /_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 Icon 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 | 14 | 15 | 16 | 17 | 18 | 19 | {% for i in (start_time_hour..end_time_hour) %} 20 | {% assign time = hourly.time[i] | date: "%H:%M" %} 21 | 22 | 23 | 24 | 25 | 26 | {% endfor %} 27 | 28 |
TimeSpeedGusts
{{ time }}{{ hourly.wind_speed_10m[i] }}{{ hourly.wind_gusts_10m[i] }}
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 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% for i in (start_time_hour..end_time_hour) %} 26 | {% assign time = hourly.time[i] | date: "%H:%M" %} 27 | 28 | 29 | 30 | 31 | 32 | 33 | {% endfor %} 34 | 35 |
TimeSpeedDirGusts
{{ time }}{{ hourly.wind_speed_10m[i] }}{{ hourly.wind_direction_10m[i] }}°{{ hourly.wind_gusts_10m[i] }}
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 | 17 | {% if entity.attributes.unit_of_measurement %} 18 | 19 | {% else %} 20 | 21 | {% endif %} 22 | 23 | {% endif %} 24 | {% endfor %} 25 | 26 |
{{ entity.friendly_name }}{{ entity.value }}{{ entity.attributes.unit_of_measurement }}{{ entity.state }}{{ entity.unit_of_measurement }}
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 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {% for i in (start_time_hour..end_time_hour) %} 30 | {% assign time = hourly.time[i] | date: "%H:%M" %} 31 | 32 | 33 | 34 | 35 | 36 | 37 | {% endfor %} 38 | 39 |
TimeWind Speed ({{ hourly_units.wind_speed_10m }})Direction ({{ hourly_units.wind_direction_10m }})Gusts ({{ hourly_units.wind_gusts_10m }})
{{ time }}{{ hourly.wind_speed_10m[i] }}{{ hourly.wind_direction_10m[i] }}°{{ hourly.wind_gusts_10m[i] }}
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 | 21 | {% if entity.attributes.unit_of_measurement %} 22 | 23 | {% else %} 24 | 25 | {% endif %} 26 | 27 | {% endif %} 28 | {% endfor %} 29 | 30 |
{{ entity.friendly_name }} {{ entity.value }}{{ entity.attributes.unit_of_measurement }} {{ entity.state }}{{ entity.unit_of_measurement }}
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 | 21 | {% if entity.attributes.unit_of_measurement %} 22 | 23 | {% else %} 24 | 25 | {% endif %} 26 | 27 | {% endif %} 28 | {% endfor %} 29 | 30 |
{{ entity.friendly_name }} {{ entity.value }}{{ entity.attributes.unit_of_measurement }} {{ entity.state }}{{ entity.unit_of_measurement }}
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 | 53 |
54 | TRMNL Display Preview Loading... 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 | ![Preview - Full](./preview/full.png) 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 | ![custom repositories](./images/HACS.png) 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 | ![checkbox mode](./images/checkboxes-mode.png) 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 | ![create label](./images/add-label.png) 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 | ntfy website 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 | NTFY Plugin 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 | 51 | 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 | 66 | 71 | 72 | {% assign counter = counter | plus: 1 %} 73 | {% endif %} 74 | {% endfor %} 75 | 76 |
DateFixture
{{ match_date }} 67 | {{ home_abbr }} 68 | {{ score }} 69 | {{ away_abbr }} 70 |
77 |
78 |
79 |
-------------------------------------------------------------------------------- /_plugins/epl-fixtures/views/quadrant.liquid: -------------------------------------------------------------------------------- 1 | 42 | 43 | 44 |
45 |
46 |
47 | 48 | 49 | 50 | 51 | 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 | 66 | 71 | 72 | {% assign counter = counter | plus: 1 %} 73 | {% endif %} 74 | {% endfor %} 75 | 76 |
DateFixture
{{ match_date }} 67 | {{ home_abbr }} 68 | {{ score }} 69 | {{ away_abbr }} 70 |
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 | 26 | 27 | 28 | 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 | 44 | 45 | 55 | 65 | 72 | 73 | {% assign counter = counter | plus: 1 %} 74 | {% endif %} 75 | {% endfor %} 76 | 77 |
DateTimeHomeAway
{{ formatted_date }}{{ match.time }} 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 |
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 |
66 | 67 | {% if match.score.ft %} 68 | {{ match.score.ft[0] }} - {{ match.score.ft[1] }} 69 | {% endif %} 70 | 71 |
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 | 15 | 16 | 17 | 18 | 19 | {% for entity in entities %} 20 | {% if entity.device_class == "temperature" %} 21 | 22 | 23 | 24 | {% if entity.attributes.unit_of_measurement %} 25 | 26 | {% else %} 27 | 28 | {% endif %} 29 | 30 | 31 | 32 | 33 | {% endif %} 34 | {% endfor %} 35 | 36 |
LocationValue
{{ entity.friendly_name }} {{ entity.value }}{{ entity.attributes.unit_of_measurement }} {{ entity.state }} {{ entity.unit_of_measurement }}
37 |
38 | 39 |
40 | 41 |
42 | Other Sensors 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | {% for entity in entities %} 52 | {% if entity.device_class != "temperature" %} 53 | 54 | 55 | 56 | {% if entity.attributes.unit_of_measurement %} 57 | 58 | {% else %} 59 | 60 | {% endif %} 61 | 62 | {% endif %} 63 | {% endfor %} 64 | 65 |
SensorValue
{{ entity.friendly_name }}{{ entity.value }}{{ entity.attributes.unit_of_measurement }}{{ entity.state }}{{ entity.unit_of_measurement }}
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 | 35 | 36 | 37 | 38 | 39 | 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 | 51 | 52 | 53 | 62 | 71 | 80 | 81 | {% assign counter = counter | plus: 1 %} 82 | {% endif %} 83 | {% endfor %} 84 | 85 |
Match #DateTimeHomeAwayScore
{{ forloop.index0 | plus: 1 }}{{ formatted_date }}{{ match.time }} 54 |
55 | {% if match.score.ft[0] > match.score.ft[1] %} 56 | {{ match.team1 }} 57 | {% else %} 58 | {{ match.team1 }} 59 | {% endif %} 60 |
61 |
63 |
64 | {% if match.score.ft[1] > match.score.ft[0] %} 65 | {{ match.team2 }} 66 | {% else %} 67 | {{ match.team2 }} 68 | {% endif %} 69 |
70 |
72 | 73 | {% if match.score.ft %} 74 | {{ match.score.ft[0] }} - {{ match.score.ft[1] }} 75 | {% else %} 76 | TBD 77 | {% endif %} 78 | 79 |
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 |
11 | 12 | 13 | 14 | 15 |
16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
MACAPI KeyDescriptionActions
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 | 11 | 12 | 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 | 33 | 34 | 43 | 44 | {% assign counter = counter | plus: 1 %} 45 | {% endif %} 46 | {% endif %} 47 | {% endfor %} 48 | 49 |
HomeScoreAway
25 |
26 | {% if match.score.ft[0] > match.score.ft[1] %} 27 | {{ match.team1 }} 28 | {% else %} 29 | {{ match.team1 }} 30 | {% endif %} 31 |
32 |
{{ match.score.ft[0] }}-{{ match.score.ft[1] }} 35 |
36 | {% if match.score.ft[1] > match.score.ft[0] %} 37 | {{ match.team2 }} 38 | {% else %} 39 | {{ match.team2 }} 40 | {% endif %} 41 |
42 |
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 | 12 | 13 | 14 | 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 | 32 | 41 | 42 | 51 | 52 | {% assign counter = counter | plus: 1 %} 53 | {% endif %} 54 | {% endif %} 55 | {% endfor %} 56 | 57 |
DateHomeScoreAway
{{ match_date }} 33 |
34 | {% if match.score.ft[0] > match.score.ft[1] %} 35 | {{ match.team1 }} 36 | {% else %} 37 | {{ match.team1 }} 38 | {% endif %} 39 |
40 |
{{ match.score.ft[0] }} - {{ match.score.ft[1] }} 43 |
44 | {% if match.score.ft[1] > match.score.ft[0] %} 45 | {{ match.team2 }} 46 | {% else %} 47 | {{ match.team2 }} 48 | {% endif %} 49 |
50 |
58 |
59 | 60 |
61 | 62 | 63 |
64 | Upcoming Fixtures 65 | 66 | 67 | 68 | 69 | 70 | 71 | 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 | 88 | 89 | 90 | 91 | 92 | {% assign counter = counter | plus: 1 %} 93 | {% endif %} 94 | {% endif %} 95 | {% endfor %} 96 | 97 |
DateTimeHomeAway
{{ match_date }}{{ match.time }}{{ match.team1 }}{{ match.team2 }}
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 | 11 | 12 | 13 | 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 | 25 | 34 | 36 | 45 | 46 | {% assign counter = counter | plus: 1 %} 47 | {% endif %} 48 | {% endif %} 49 | {% endfor %} 50 | 51 |
DateHomeScoreAway
{{ match_date }} 26 |
27 | {% if match.score.ft[0] > match.score.ft[1] %} 28 | {{ match.team1 }} 29 | {% else %} 30 | {{ match.team1 }} 31 | {% endif %} 32 |
33 |
{{ match.score.ft[0] }}-{{ match.score.ft[1] 35 | }} 37 |
38 | {% if match.score.ft[1] > match.score.ft[0] %} 39 | {{ match.team2 }} 40 | {% else %} 41 | {{ match.team2 }} 42 | {% endif %} 43 |
44 |
52 |
53 | 54 |
55 | 56 | 57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 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 | 76 | 77 | 78 | 79 | 80 | {% assign counter = counter | plus: 1 %} 81 | {% endif %} 82 | {% endif %} 83 | {% endfor %} 84 | 85 |
DateTimeHomeAway
{{ match_date }}{{ match.time }}{{ match.team1 }}{{ match.team2 }}
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 | 12 | 13 | 14 | 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 | 26 | 35 | 37 | 46 | 47 | {% assign counter = counter | plus: 1 %} 48 | {% endif %} 49 | {% endif %} 50 | {% endfor %} 51 | 52 |
DateHomeScoreAway
{{ match_date }} 27 |
28 | {% if match.score.ft[0] > match.score.ft[1] %} 29 | {{ match.team1 }} 30 | {% else %} 31 | {{ match.team1 }} 32 | {% endif %} 33 |
34 |
{{ match.score.ft[0] }} - {{ match.score.ft[1] 36 | }} 38 |
39 | {% if match.score.ft[1] > match.score.ft[0] %} 40 | {{ match.team2 }} 41 | {% else %} 42 | {{ match.team2 }} 43 | {% endif %} 44 |
45 |
53 |
54 | 55 |
56 | 57 | 58 |
59 | Upcoming Fixtures 60 | 61 | 62 | 63 | 64 | 65 | 66 | 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 | 78 | 79 | 80 | 81 | 82 | {% assign counter = counter | plus: 1 %} 83 | {% endif %} 84 | {% endif %} 85 | {% endfor %} 86 | 87 |
DateTimeHomeAway
{{ match_date }}{{ match.time }}{{ match.team1 }}{{ match.team2 }}
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 | ![Code Clock Preview](_plugins/code-clock/Preview/full.png) 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 | ![Home Assistant Preview](_plugins/home-assistant-trmnl/Preview/full.png) 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 | ![NTFY Plugin Preview](_plugins/NTFY/Preview/full.png) 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 | ![My Agenda Preview](_plugins/my-agenda/Preview/full.png) 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 | ![Currency Exchange Preview](_plugins/currency-exchange/Preview/full.png) 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 | ![EPL Fixtures Preview](_plugins/epl-fixtures/Preview/full.png) 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 | ![Random Fact Preview](_plugins/random-fact/Preview/full.png) 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 | ![Random Joke Preview](_plugins/random-joke/Preview/full.png) 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 | ![Wind Speed & Direction Preview](_plugins/wind-speed-direction/Preview/full.png) 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 | ![TRMNL Broadcast Preview](_plugins/trmnl-broadcast/Preview/full.png) 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 | --------------------------------------------------------------------------------