Control a browser-based display with a friendly CLI. Built for 1280×400 USB monitors, works in any modern browser.
- What is hdisplay? (below)
- Highlights
- Requirements
- Quick start
- Using the CLI
- Templates
- Playlists
- Assets & media
- Discovery
- Run with Docker
- Raspberry Pi setup
- Configuration
- Security
- Troubleshooting
- Appendix: API (optional)
- Development
- Testing
- Captures and previews
- License
- Real-time updates to a fullscreen browser display (Chromium kiosk on Raspberry Pi supported)
- Simple CLI for control (status, set HTML, notifications, templates)
- Templates:
- Animated scrolling text (velocity-based)
- Image/video carousel with fade transitions (uploads and URLs)
- Message banner (title/subtitle)
- Snake (auto-play, ambient)
- TimeLeft (meeting minutes remaining)
- Weather (7-day forecast)
- Aquarium (ambient canvas simulation)
- Assets & media:
- Upload/download/delete files under
/uploads - Push image/video from file or URL and display immediately (ephemeral, no disk write unless requested)
- Upload/download/delete files under
- Discovery on LAN via mDNS (
_hdisplay._tcp) - Mac preview helper to open a 1280×400 window quickly
- Node.js >= 20
- macOS or Linux (Raspberry Pi OS/Debian supported)
- Install dependencies
npm install- Start the server (default: http://localhost:3000)
npm start- Preview (macOS optional)
./scripts/mac-preview.sh- Discover and set CLI target (on the same LAN) Install the CLI command (one-time, from the repo root):
npm linkThen:
hdisplay discover --set- Check server status
hdisplay statusThe CLI is the primary way to control the display. Most actions are single commands.
- Set raw HTML
hdisplay set '<b>Hello</b>'- Send a notification (auto-dismiss)
hdisplay notify 'Heads up' -l warn -d 2000- Clear display
hdisplay clearBuilt-in templates render common layouts. Apply them with the CLI and pass data.
Templates live in templates/ if you want to author your own.
Authoring guide: see TEMPLATES.md for how to build templates and write validators.
- List templates
hdisplay templates- Apply a template with data (JSON)
hdisplay template message-banner --data '{"title":"Hello","subtitle":"World"}'Preferred: pass data with flags — no JSON required.
- Scalars:
--text "Hello"→{ text: "Hello" } - Numbers:
--velocity 120→{ velocity: 120 } - Arrays: repeat flags
--items A --items B→{ items: ["A","B"] } - Nested: dot paths
--theme.bg '#000'→{ theme: { bg: "#000" } } - Booleans:
--wrap= true,--no-wrap= false
Also supported (JSON):
- Inline:
--data '{"text":"Hello"}' - From file:
--data-file ./data.json - From stdin:
--data -(reads JSON from stdin)
hdisplay template animated-text --text "Hello world" --velocity 120
# or JSON
hdisplay template animated-text --data '{"text":"Hello world","velocity":120}'Preview
Notes:
- Velocity is objective: higher = faster, independent of text length.
- Starts fully offscreen on the right and exits fully on the left, auto-resizes.
You can pass either /uploads/... paths or absolute http(s):// URLs; both work, and you can mix them in one list.
hdisplay template carousel \
--items /uploads/a.jpg \
--items /uploads/b.mp4 \
--items /uploads/c.jpg \
--duration 3000
hdisplay template carousel \
--items http://localhost:3000/uploads/a.jpg \
--items https://picsum.photos/seed/alpha/1280/400 \
--items https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4 \
--duration 4000
# or JSON
hdisplay template carousel --data '{"items":["/uploads/a.jpg","/uploads/b.mp4","/uploads/c.jpg"],"duration":3000}'Preview
Notes:
- Sources can be either:
- Paths under
/uploads(e.g.,/uploads/xyz.jpg) served by this server - Absolute
http(s)://URLs to remote content
- Paths under
- Relative paths starting with
/are resolved against the display server origin. - You can mix uploads and URLs in the same carousel.
- Videos play while visible.
--durationis per-slide time in ms.
hdisplay template message-banner --title "hdisplay" --subtitle "example banner"
# or JSON
hdisplay template message-banner --data '{"title":"hdisplay","subtitle":"example banner"}'Preview
hdisplay template webp-loop --url /uploads/your_anim.webp --fit cover --position "50% 50%"Options
- url (required): path under /uploads or absolute URL to a .webp
- fit (optional): "cover" (default) or "contain"
- position (optional): CSS object-position (e.g., "50% 50%", "top left")
- rendering/pixelated (optional): set
rendering:"pixelated"orpixelated:trueto use blocky scaling for low-res sources
Examples
# Contain and keep center, pixelated upscale
hdisplay template webp-loop --url /uploads/anim.webp --fit contain --position "50% 50%" --rendering pixelated
# Default smooth scaling
hdisplay template webp-loop --url /uploads/anim.webp
# or JSON
hdisplay template webp-loop --data '{"url":"/uploads/anim.webp","fit":"contain","position":"50% 50%","rendering":"pixelated"}'Preview
Video credit: kieutruongphoto on Pixabay
hdisplay template snake --cellSize 20 --tickMs 100
# or JSON
hdisplay template snake --data '{"cellSize":20,"tickMs":100}'Notes
- Auto-plays with safe pathing; optional wrap mode via data
{ "wrap": true }.
Preview
hdisplay template timeleft --minutes 15 --label "Time left"
hdisplay template timeleft --minutes 135 --label "Time left" --theme.labelColor "#fff"
# or JSON
hdisplay template timeleft --data '{"minutes":15,"label":"Time left"}'
hdisplay template timeleft --data '{"minutes":135,"label":"Time left","theme":{"labelColor":"#fff"}}'Rules
-
90 minutes shows
Hh Mm; otherwiseXm - Color thresholds: >8 green, >4 amber, ≤4 red (value only); label uses
theme.labelColor(default white)
Preview
Render a 6-day forecast using Tomorrow.io (default) or OpenWeatherMap One Call 3.0 with server-side caching. Supports city/state/country, ZIP, or raw coordinates, dark or light mode, and optional theme overrides.
Preview
Examples
# City, state, country; imperial units
hdisplay template weather --location "Santa Rosa, CA, US" --units F
# Coordinates; metric units, refresh hourly
hdisplay template weather --location "38.44,-122.71" --units C --refresh-interval 60
# Light mode with custom colors
hdisplay template weather \
--location "Portland, OR, US" \
--units F \
--no-dark-mode \
--theme.bg "#ffffff" \
--theme.text "#111111" \
--theme.accent "#00a8ff"
# or JSON
hdisplay template weather --data '{
"location":"Santa Rosa, CA, US",
"units":"F",
"refreshInterval":30,
"showConditionText":true,
"darkMode":true
}'Options (data fields)
- location (required): string. Supported forms:
- "City[, State][, Country]" (geocoded)
- "lat,lon" (decimal degrees)
- "ZIP,cc" (e.g., "97201,US")
- units: "C" (default) or "F"
- refreshInterval: minutes between updates (10–120, default 30)
- showConditionText: boolean (default true)
- darkMode: boolean (default true)
- theme overrides (optional):
theme.bg,theme.text,theme.accent,theme.divider,theme.fontFamily
Notes
- Requires an OpenWeatherMap or Tomorrow.io API key (depending on provider). See Configuration below.
- Data is fetched server-side and cached per
location+unitsforrefreshIntervalminutes. - Up to 6 days are shown. This cap is applied regardless of provider.
- If you see HTTP 401 from
/api/weather, your API key is missing or invalid. 404 indicates the location couldn’t be geocoded. - Coordinates (
lat,lon) skip geocoding and are most reliable. - When using Tomorrow.io, condition icons are based on
weatherCodeMaxfor each day and mapped to OWM-style icon families for consistency.
A self-contained canvas aquarium with fish, jellyfish, a turtle, and crabs. Periodic feeding with food flakes (about every 2.5 minutes), simple flocking, and adaptive quality for Raspberry Pi.
hdisplay template aquariumPreview
Notes
- Creatures: mixed fish across depth zones, 2 jellyfish, 1 turtle, and 2 crabs
- Behaviors: nearest-flake feeding with “jostling” separation, smooth turning, lightweight flocking for midwater fish
- Atmosphere: sand, rocks/coral, bubbles
- Performance: adaptive LOD staggers fish updates based on frame time
hdisplay template pacmanNotes
- Generates a symmetric maze and plays automatically: Pac‑Man seeks pellets with BFS and avoids nearby ghosts; ghosts roam randomly.
- Designed for the 1280×400 display. The grid widens to fill columns without clipping vertically.
Preview
An ambient visualization that smoothly crossfades between curated regions of the Mandelbrot set. Features progressive rendering, multiple color schemes, and smooth transitions optimized for both desktop and Raspberry Pi.
# Default tour (10 curated locations, ocean color scheme)
hdisplay template mandelbrot
# Faster cycling with rainbow colors
hdisplay template mandelbrot --duration 5000 --colorScheme rainbow
# High-quality deep zoom with longer transitions
hdisplay template mandelbrot --maxIterations 200 --transitionMs 3000
# Pi-optimized (lower quality for better performance)
hdisplay template mandelbrot --maxIterations 50 --progressive false
# Custom locations (JSON required for complex data)
hdisplay template mandelbrot --data '{
"locations": "custom",
"customLocations": [
{"name": "Deep Spiral", "cx": -0.7269, "cy": 0.1889, "scale": 0.00001}
],
"duration": 8000,
"colorScheme": "fire"
}'Preview
Options (data fields)
- duration: milliseconds per location (default 10000)
- transitionMs: crossfade duration in milliseconds (default 2000)
- colorScheme: "ocean" (default), "fire", "forest", "mono", or "rainbow"
- maxIterations: base iteration count, higher = more detail but slower (default 100)
- progressive: enable progressive rendering passes (default true)
- shuffle: randomize location order (default false)
- locations: "default" (curated) or "custom"
- customLocations: array of custom zoom locations (requires locations: "custom")
Custom location format:
{
"name": "Location Name",
"cx": -0.75,
"cy": 0.1,
"scale": 0.01
}Built-in locations include: Overview, Seahorse Valley, Elephant Valley, Triple Spiral, Mini Mandelbrot, Dendrite Forest, Spiral Galaxy, Lightning Branches, Jeweled Necklace, and Feather Tip.
Notes
- First location shows progressive rendering for visual feedback; subsequent locations render off-screen then crossfade
- Performance automatically adapts on Raspberry Pi with lower iteration counts
- Smooth coloring eliminates banding artifacts
- Respects reduced motion preferences
Continuously scrolling equity quotes plus simple forex pairs with optional 7‑day sparklines, adaptive scroll timing, light/dark theming, and graceful handling of partial API failures / rate limits. Supports Finnhub (default) or Alpha Vantage providers configured via config.json (stocks.provider) or environment variables.
# Basic (default provider, dark theme)
hdisplay template stock-ticker --symbols MSFT --symbols GOOG --symbols GBPUSD
# Faster scroll + custom precision + hide change
hdisplay template stock-ticker --symbols AAPL --symbols NVDA --scrollSpeed 90 --display.precision 3 --display.showChange false
# Light theme overrides
hdisplay template stock-ticker \
--symbols AAPL --symbols GOOGL --symbols MSFT \
--theme.bg '#ffffff' --theme.text '#111' \
--theme.positive '#00b861' --theme.negative '#ff2f5d' \
--display.separator '/' --display.precision 2
# Provide complex data via JSON file
hdisplay template stock-ticker --data-file stocks.jsonExample stocks.json:
{
"symbols": ["AAPL", "GOOGL", "MSFT", "GBPUSD"],
"scrollSpeed": 70,
"updateInterval": 5,
"showSparkline": true,
"darkMode": true,
"theme": { "bg": "#000000", "text": "#ffffff" },
"display": { "showChange": true, "precision": 2, "separator": " • " }
}Preview
Options (data fields)
- symbols: array of symbols & simple forex pairs (e.g. GBPUSD or GBP/USD)
- scrollSpeed: relative speed used to derive animation duration (20–200, default 60)
- updateInterval: minutes between refresh (1–60, default 5)
- showSparkline: include 7‑day mini chart (default true)
- darkMode: false switches to light palette (default true)
- theme: overrides { bg, text, positive, negative, neutral, separator, fontFamily }
- display.showChange: show +/- absolute & percent change (default true)
- display.precision: price decimal places (0–6, default 2; forex auto uses 4)
- display.separator: string between metadata elements (default " • ")
Provider selection
- Set in
config.json:{ "stocks": { "provider": "finnhub" } }oralphavantage - API keys required: FINNHUB_API_KEY or ALPHA_VANTAGE_API_KEY (env) or
config.apiKeys - Finnhub free tier: stock quotes OK, forex pairs unsupported (card shows placeholder message)
- Alpha Vantage free tier: stricter rate limit (12s spacing enforced internally) but supports basic forex pairs
Create a rotating sequence of templates. The server plays items in order, loops, and persists across restarts. Applying a one-off template or push temporarily overrides playback; rotation resumes automatically.
- Show current playlist and dwell time
hdisplay playlist:list- Add items (mix different templates)
hdisplay playlist:add carousel --data '{"items":["https://picsum.photos/id/1015/1280/400","https://picsum.photos/id/1022/1280/400"],"duration":4000}'
hdisplay playlist:add animated-text --data '{"text":"Welcome to the lab","velocity":120}'
hdisplay playlist:add message-banner --data '{"title":"Meeting","subtitle":"Room A"}'Or using flags (no JSON):
hdisplay playlist:add carousel \
--items https://picsum.photos/id/1015/1280/400 \
--items https://picsum.photos/id/1022/1280/400 \
--duration 4000
hdisplay playlist:add animated-text --text "Welcome to the lab" --velocity 120
hdisplay playlist:add message-banner --title "Meeting" --subtitle "Room A"Here are more detailed examples showing how to use schema-aware flags with different templates:
# Weather forecast with custom theme
hdisplay playlist:add weather \
--location "San Francisco, CA, US" \
--units F \
--refresh-interval 30 \
--no-dark-mode \
--theme.bg "#ffffff" \
--theme.text "#000000" \
--theme.accent "#007acc"
# Stock ticker with multiple symbols and custom display
hdisplay playlist:add stock-ticker \
--symbols AAPL \
--symbols GOOGL \
--symbols MSFT \
--symbols GBPUSD \
--scroll-speed 80 \
--update-interval 5 \
--display.precision 2 \
--display.separator " • " \
--theme.bg "#000000" \
--theme.text "#ffffff"
# TimeLeft countdown with custom theme
hdisplay playlist:add timeleft \
--minutes 45 \
--label "Break ends in" \
--theme.label-color "#ff6b6b" \
--theme.value-color "#4ecdc4"
# Snake game with custom settings
hdisplay playlist:add snake \
--cell-size 25 \
--tick-ms 120 \
--wrap
# Mandelbrot with custom color scheme and timing
hdisplay playlist:add mandelbrot \
--duration 15000 \
--transition-ms 2500 \
--color-scheme fire \
--max-iterations 150 \
--shuffle
# WebP animation with custom positioning
hdisplay playlist:add webp-loop \
--url /uploads/animation.webp \
--fit contain \
--position "50% 50%" \
--rendering pixelated
# Simple clock with custom styling
hdisplay playlist:add simple-clock \
--format "HH:mm:ss" \
--timezone "America/New_York" \
--theme.color "#00ff88" \
--theme.font-size "72px"
# Multiple items with mixed templates
hdisplay playlist:add carousel \
--items /uploads/photo1.jpg \
--items https://picsum.photos/1280/400 \
--duration 5000
hdisplay playlist:add animated-text \
--text "Welcome to our office" \
--velocity 100
hdisplay playlist:add message-banner \
--title "Important Notice" \
--subtitle "Please read the safety guidelines"These examples demonstrate the full range of schema-aware flag usage across different template types, from simple scalars to complex nested objects and arrays.
- Remove by index or by id (first match)
```bash
hdisplay playlist:remove 0
hdisplay playlist:remove animated-text
- Clear all items
hdisplay playlist:clear- Set dwell per item (ms)
hdisplay playlist:delay 5000Notes
- Rotation auto-starts when the playlist has items.
hdisplay clearalso clears the playlist and stops rotation.- Data for each item is validated by the template’s validator.
Use uploads when you want media persisted on disk and accessible under /uploads. Use push for one-off, immediate display without writing to disk.
- Upload a file (returns a URL under
/uploads/...)
hdisplay assets:upload ./examples/banner.svg- List uploaded assets
hdisplay assets:list- Display an uploaded image or video by pushing a URL
hdisplay push:image --url http://localhost:3000/uploads/<filename>
hdisplay push:video --url http://localhost:3000/uploads/<filename>- Delete an upload
hdisplay assets:delete <filename># Image from local file (served from memory)
hdisplay push:image --file ./examples/banner.svg
# Image from URL
hdisplay push:image --url http://example.local/pic.jpg
# Persist to disk instead of in-memory (when using --file)
hdisplay push:image --file ./examples/banner.svg --persist
# Video (file or URL)
hdisplay push:video --url http://example.local/clip.mp4Ephemeral files are kept in-memory for ~10 minutes by default.
Find the server on your LAN and set it as the default CLI target.
It advertises an mDNS service _hdisplay._tcp.
hdisplay discover --setMost users only need the CLI. If you prefer HTTP, an unauthenticated local API mirrors the CLI. Use on trusted networks only.
- GET
/– Display client - GET
/api/status– Current state - POST
/api/content– Body:{ content: string } - POST
/api/notification– Body:{ message: string, duration?: number, level?: 'info'|'warn'|'error'|'success' } - POST
/api/clear - GET
/api/templates– List templates and placeholders - POST
/api/template/:id– Body:{ data?: object } - GET
/api/playlist– Current playlist{ items: Array<{ id, data }>, delayMs } - PUT
/api/playlist– Replace playlist body{ items: Array<{ id, data }>, delayMs? } - POST
/api/playlist/items– Append{ id, data? }, returns{ index } - DELETE
/api/playlist/items/:index– Remove by index - DELETE
/api/playlist/items/by-id/:id– Remove first match by id - POST
/api/playlist/delay– Set dwell{ delayMs } - POST
/api/upload– multipart form fieldfile - GET
/api/uploads– List files{ files: [{ name, url }] } - DELETE
/api/uploads/:name - POST
/api/push/image– multipartfileOR JSON{ url }, query/bodypersist=true|false - POST
/api/push/video– same as above - GET
/ephemeral/:id– Serve in-memory pushed content (short-lived) - GET
/api/weather– Query:location(string),units=C|F(default C),refresh= minutes (10–120). Returns{ location: { name,country,lat,lon }, days: [{ date, low, high, icon, description }], units }.
Notes
- POST
/api/clearalso clears the playlist and stops rotation.
On the Pi:
curl -sSL https://raw.githubusercontent.com/ewilderj/hdisplay/main/scripts/setup-pi.sh | bashThis installs Node.js and Chromium, sets up the server as a systemd service, and configures Chromium to auto-launch in kiosk mode pointing at http://localhost:3000.
Node version selection (optional)
- The setup script installs Node.js using NodeSource. By default it installs Node 22.x.
- To choose a different major (e.g., 24), set HDS_NODE_MAJOR when running the script:
HDS_NODE_MAJOR=24 curl -sSL https://raw.githubusercontent.com/ewilderj/hdisplay/main/scripts/setup-pi.sh | bashThe script will install Node if missing, or upgrade if the installed major doesn’t match.
The setup script installs templated systemd units:
- [email protected] – runs the server as the specified user
- [email protected] & [email protected] – runs a periodic health probe against /healthz
Health probe script: scripts/healthcheck.sh
Manual management examples:
- sudo systemctl status [email protected]
- sudo systemctl restart [email protected]
- sudo systemctl status [email protected]
- sudo systemctl list-timers | grep hdisplay-health
Build and run:
docker build -t hdisplay .
docker run --rm -p 3000:3000 \
-v "$(pwd)/uploads:/app/uploads" \
-v "$(pwd)/data:/app/data" \
--name hdisplay hdisplayDocker quickstart (alternate port 3001):
docker run --rm -d -p 3001:3000 \
-v "$(pwd)/uploads:/app/uploads" \
-v "$(pwd)/data:/app/data" \
--name hdisplay-3001 hdisplay
curl -fsS http://localhost:3001/healthz
hdisplay config --server http://localhost:3001
hdisplay template animated-text --text "Hello from Docker" --velocity 120
hdisplay status
# macOS preview (optional)
PORT=3001 ./scripts/mac-preview.shNotes:
- uploads/ and data/ are mounted as volumes so content and state persist across container restarts.
- If port 3000 is in use on your host, map another port (e.g., 3001:3000) and point the CLI to it.
Or with docker-compose (see docker-compose.yml):
docker compose up --buildEnvironment variables:
PORT– Server port (default 3000)HDS_UPLOADS_DIR– Uploads directory (default<repo>/uploads)HDS_EPHEMERAL_TTL_MS– Ephemeral in-memory file TTL in ms (default ~600k)
Weather
OPENWEATHERMAP_API_KEY– Your OpenWeatherMap API key (when using the OWM provider)TOMORROW_API_KEY– Your Tomorrow.io API key (required by default)- Optional config file: Create
config.jsonin the repo root (or setHDS_CONFIG_PATHto a JSON file) with:
{
"weather": { "provider": "tomorrowio" },
"apiKeys": {
"openweathermap": "<owm-key>",
"tomorrowio": "<tomorrow-key>"
}
}Provider selection: set weather.provider to tomorrowio (default) or openweathermap. The server looks for provider API keys in environment variables first, then in config.json.
CLI config is stored at ~/.hdisplay.json (set via hdisplay config --server <url> or discover --set).
There is no built-in authentication, authorization, or TLS.
- Do not expose this service directly to the internet.
- Run on a trusted LAN or behind a firewall/reverse proxy.
- Anyone who can reach the server can change the display, upload files, and trigger playback.
- For remote access, put it behind a reverse proxy that adds HTTPS and authentication.
# Start server
npm start
# Dev open browser (macOS)
./scripts/mac-preview.sh
# Run tests
npm testJest + Supertest covers the uploads API:
- Upload validation (missing file)
- Upload + list
- Static serving from
/uploadsand delete cleanup
The screenshots and MP4 links in this README are generated automatically.
- Requirements: Playwright (dev dependency) and ffmpeg on your PATH for MP4 output. If ffmpeg is missing, WEBM will still be produced.
- Regenerate all assets:
hdisplay capture:all- Capture a single template:
hdisplay capture:template <templateId>- Generate the HTML gallery for quick review:
hdisplay capture:galleryOutputs:
- Screenshots:
captures/screenshots/<template>.png - Videos:
captures/videos/<template>.webmandcaptures/videos/<template>.mp4
Notes:
- Per-template capture profiles live in
capture-profiles/and can set readiness detection, sample data, and a small initial trim to remove early frames. Seecapture/README.mdfor details.
- CLI chalk error (TypeError chalk.red is not a function): fixed by using normalized import; ensure
npm install. - mDNS discovery issues: ensure same network, no firewall blocking multicast; server logs should print mDNS publish success.
- Server won’t start: check Node version (>= 20) and port availability (
PORTin use?) - mac-preview script: ensure Chrome app path or allow AppleScript for Safari.
MIT










