From a88841d894627a74e08fa14210a9b6952387bf56 Mon Sep 17 00:00:00 2001 From: ingm4r <6264197+ingm4r@users.noreply.github.com> Date: Sun, 8 Feb 2026 12:00:10 +0100 Subject: [PATCH] Add HTTP serve mode for Terminus/BYOS self-hosted support Instead of pushing to TRMNL cloud webhooks, the collector can now run an HTTP server that Terminus Extensions poll for JSON data. This enables self-hosted Terminus users to use the Servarr dashboard without cloud webhook endpoints. - Add --serve, --port, --host CLI args and serve config section - Add DataHandler HTTP server (stdlib, no new deps) serving cached payloads at /data/ - Add last_updated_local field for Terminus (lacks trmnl.user.utc_offset) - Add SERVE_PORT env var and EXPOSE 8080 to Dockerfile - Add docs/terminus.md setup guide - Webhook mode remains default and fully backward compatible Co-Authored-By: Claude Opus 4.6 --- README.md | 6 +++ collector/Dockerfile | 12 ++++- collector/config.example.yaml | 9 +++- collector/docker-compose.yml | 5 ++ collector/trmnl_collector.py | 81 +++++++++++++++++++++++++++++ docs/terminus.md | 97 +++++++++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 docs/terminus.md diff --git a/README.md b/README.md index 4c6a5d6..3784082 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,12 @@ docker run -d \ | `CALENDAR_DAYS_BEFORE` | Days back for calendar | No (default: 0) | | `CALENDAR_ONLY` | Only send calendar data (true/false) | No (default: false) | +### Terminus / BYOS (Self-Hosted) + +If you run a self-hosted [Terminus](https://github.com/usetrmnl/byos_hanami) +instance, the collector can serve data via HTTP instead of pushing to webhooks. +See the [Terminus Setup Guide](docs/terminus.md) for details. + ### Python Script (No Docker) Run the collector directly with Python if you prefer not to use Docker: diff --git a/collector/Dockerfile b/collector/Dockerfile index da875e9..d130a19 100644 --- a/collector/Dockerfile +++ b/collector/Dockerfile @@ -42,6 +42,8 @@ ENV CALENDAR_DAYS_BEFORE="0" ENV CALENDAR_ONLY="" ENV INTERVAL="0" ENV TZ="UTC" +ENV SERVE_PORT="" +EXPOSE 8080 # Entry point script COPY --chmod=755 <<'EOF' /app/entrypoint.sh @@ -51,7 +53,11 @@ set -e # Check if config file exists if [[ -f "/app/config.yaml" ]]; then echo "Starting with config file..." - exec python /app/trmnl_collector.py --config /app/config.yaml + ARGS="--config /app/config.yaml" + if [[ -n "$SERVE_PORT" ]]; then + ARGS="$ARGS --serve --port $SERVE_PORT" + fi + exec python /app/trmnl_collector.py $ARGS fi # Fall back to environment variables (single instance mode) @@ -95,6 +101,10 @@ if [[ -n "$TZ" ]]; then ARGS="$ARGS -z $TZ" fi +if [[ -n "$SERVE_PORT" ]]; then + ARGS="$ARGS --serve --port $SERVE_PORT" +fi + # Run the Python collector exec python /app/trmnl_collector.py $ARGS EOF diff --git a/collector/config.example.yaml b/collector/config.example.yaml index 0d064b0..d470c92 100644 --- a/collector/config.example.yaml +++ b/collector/config.example.yaml @@ -15,6 +15,13 @@ defaults: calendar_days: 7 # Days forward for calendar calendar_days_before: 0 # Days back for calendar (historical) +# Optional: HTTP server for Terminus/BYOS Extensions +# When enabled, the collector serves data via HTTP so Terminus can poll for it. +# serve: +# enabled: true +# port: 8080 +# host: 0.0.0.0 + # Instance definitions # Each instance sends to its own TRMNL webhook instances: @@ -24,7 +31,7 @@ instances: - name: sonarr url: http://sonarr:8989 api_key: your-sonarr-api-key - webhook: https://usetrmnl.com/api/custom_plugins/your-sonarr-webhook-id + webhook: https://usetrmnl.com/api/custom_plugins/your-sonarr-webhook-id # Optional when using serve mode # type: sonarr # Optional - auto-detected from URL # ============================================ diff --git a/collector/docker-compose.yml b/collector/docker-compose.yml index 8ca5965..e0476e4 100644 --- a/collector/docker-compose.yml +++ b/collector/docker-compose.yml @@ -27,6 +27,11 @@ services: # Timezone (can also be set in config.yaml) - TZ=America/New_York network_mode: host + # --- Terminus/BYOS: uncomment to enable HTTP serve mode --- + # ports: + # - "8080:8080" + # environment: + # - SERVE_PORT=8080 # ============================================ # ALTERNATIVE: Environment Variable Mode diff --git a/collector/trmnl_collector.py b/collector/trmnl_collector.py index bcc0214..0a5975d 100644 --- a/collector/trmnl_collector.py +++ b/collector/trmnl_collector.py @@ -26,6 +26,8 @@ from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from zoneinfo import ZoneInfo +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler import requests import yaml @@ -604,6 +606,7 @@ def collect(self) -> Dict[str, Any]: 'app_name': display_name, 'app_type': app_type, 'last_updated': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'last_updated_local': datetime.now(ZoneInfo(self.timezone)).strftime('%Y-%m-%d %H:%M') if self.timezone else datetime.now().strftime('%Y-%m-%d %H:%M'), 'timezone': tz_abbrev, 'calendar': calendar, } @@ -621,6 +624,7 @@ def collect(self) -> Dict[str, Any]: 'app_name': display_name, 'app_type': app_type, 'last_updated': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'last_updated_local': datetime.now(ZoneInfo(self.timezone)).strftime('%Y-%m-%d %H:%M') if self.timezone else datetime.now().strftime('%Y-%m-%d %H:%M'), 'timezone': tz_abbrev, 'health': health, 'queue': queue, @@ -664,6 +668,65 @@ def send(self, payload: Dict[str, Any]) -> bool: return False +# --- HTTP serve mode for Terminus/BYOS --- + +_serve_data: Dict[str, Dict[str, Any]] = {} +_serve_lock = threading.Lock() + + +def store_payload(name: str, payload: Dict[str, Any]): + """Cache latest payload for HTTP serving.""" + data = payload.get('merge_variables', payload) + with _serve_lock: + _serve_data[name] = data + + +class DataHandler(BaseHTTPRequestHandler): + """HTTP handler that serves cached Servarr data as JSON.""" + + def do_GET(self): + path = self.path.rstrip('/') + + if path == '' or path == '/': + with _serve_lock: + instances = { + name: f'/data/{name}' for name in _serve_data + } + self._json_response(200, {'instances': instances}) + return + + if path.startswith('/data/'): + name = path[6:] + with _serve_lock: + data = _serve_data.get(name) + if data is None: + self._json_response(404, {'error': f'Instance "{name}" not found'}) + return + self._json_response(200, data) + return + + self.send_response(404) + self.end_headers() + + def _json_response(self, status: int, body: Any): + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(body).encode()) + + def log_message(self, format, *args): + logger.debug(f"HTTP: {args[0]}") + + +def start_server(host: str, port: int): + """Start HTTP server in a daemon thread.""" + server = HTTPServer((host, port), DataHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + logger.info(f"HTTP server listening on {host}:{port}") + return server + + def load_config(config_path: str) -> Dict[str, Any]: """Load configuration from YAML file.""" with open(config_path, 'r') as f: @@ -721,6 +784,7 @@ def run_collection(collectors: List[ServarrCollector]) -> bool: for collector in collectors: try: payload = collector.collect() + store_payload(collector.name, payload) if collector.send(payload): succeeded.append(collector.name) else: @@ -780,6 +844,9 @@ def main(): parser.add_argument('-i', '--interval', type=int, default=0, help='Collection interval in seconds (0 = run once)') parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output') parser.add_argument('--dry-run', action='store_true', help='Print JSON, don\'t send to webhook') + parser.add_argument('--serve', action='store_true', help='Start HTTP server for Terminus/BYOS') + parser.add_argument('--port', type=int, default=8080, help='HTTP server port (default: 8080)') + parser.add_argument('--host', default='0.0.0.0', help='HTTP server bind address (default: 0.0.0.0)') parser.add_argument('--version', action='version', version=f'%(prog)s {VERSION}') args = parser.parse_args() @@ -821,6 +888,20 @@ def signal_handler(signum, frame): signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) + # Start HTTP server if requested + serve = args.serve + if args.config: + serve_config = config.get('serve', {}) + serve = serve or serve_config.get('enabled', False) + serve_port = serve_config.get('port', args.port) + serve_host = serve_config.get('host', args.host) + else: + serve_port = args.port + serve_host = args.host + + if serve: + start_server(serve_host, serve_port) + # Run collection if interval > 0: logger.info(f"Running continuously with {interval}s interval (Ctrl+C to stop)") diff --git a/docs/terminus.md b/docs/terminus.md new file mode 100644 index 0000000..e9980f5 --- /dev/null +++ b/docs/terminus.md @@ -0,0 +1,97 @@ +# Terminus / BYOS Setup + +Guide for using the Servarr collector with a self-hosted +[Terminus (BYOS)](https://github.com/usetrmnl/byos_hanami) instance. + +## Architecture + +Instead of pushing data to TRMNL cloud webhooks, the collector runs a +lightweight HTTP server. A Terminus Extension polls this endpoint for +JSON data and renders the same Liquid templates server-side. + +``` +Servarr APIs → Collector ←── GET JSON ── Terminus Extension → render → device +``` + +## 1. Configure the Collector + +In your `config.yaml`, enable serve mode and remove or omit the `webhook` field: + +```yaml +interval: 900 +timezone: America/New_York + +serve: + enabled: true + port: 8080 + +instances: + - name: sonarr + url: http://sonarr:8989 + api_key: your-api-key + # webhook is not needed in serve mode +``` + +## 2. Run with Docker Compose + +```yaml +services: + trmnl-collector: + image: ghcr.io/pythcon/trmnl-servarr-collector:latest + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./config.yaml:/app/config.yaml:ro + environment: + - TZ=America/New_York + - SERVE_PORT=8080 +``` + +Or set `SERVE_PORT=8080` to enable serve mode without a config file. + +## 3. Verify the Endpoint + +```bash +curl http://localhost:8080/ +# Returns: {"instances": {"sonarr": "/data/sonarr"}} + +curl http://localhost:8080/data/sonarr +# Returns: {"app_name": "Sonarr", "queue": {...}, ...} +``` + +> **Note:** The first request after startup may return 404 until the +> first collection cycle completes (up to `interval` seconds). + +## 4. Create a Terminus Extension + +1. Open your Terminus dashboard +2. Go to **Extensions → New Extension** +3. Configure: + - **Name:** Servarr - Sonarr (or your app name) + - **URI:** `http://trmnl-collector:8080/data/sonarr` + - **Kind:** Poll + - **Schedule:** 15 minutes (match your collector interval) + - **Template:** paste the contents of `src/full.liquid` + - **Model:** select your device model +4. Save and add the generated screen to your device playlist + +Repeat for each Servarr instance (radarr, lidarr, etc.), using the +appropriate `/data/` path. + +## Template Notes + +The existing Liquid templates work on Terminus with these caveats: + +- **Settings:** All `trmnl.plugin_settings` values have `| default:` fallbacks, + so defaults activate automatically (Dashboard mode, all sections shown, 3 items each). +- **Timestamps:** `trmnl.user.utc_offset` is not available on Terminus. The collector + includes a `last_updated_local` field with a pre-formatted local timestamp as a + workaround. To use it in the template title bar, replace: + ```liquid + {{ last_updated | date: "%s" | plus: trmnl.user.utc_offset | date: "%Y-%m-%d %H:%M" }} + ``` + with: + ```liquid + {{ last_updated_local }} + ```