LocalPrinter builds a local newspaper-style image from plugin data and sends it to a 58mm serial thermal printer. It can include Telegram messages, news, Spotify playlist changes, weather, XKCD, Wikipedia rabbit holes, AI trivia and word features, and a poster quote. Development mode renders the same output to output/ without requiring printer or GPIO hardware.
The application targets Node.js 24 and is written in strict TypeScript using native ESM. Production runs compiled JavaScript from dist/; development and tests run TypeScript through tsx.
- Node.js 24 through
nvm(see.nvmrc) - A Raspberry Pi or another host with a serial port for physical printing
- Cairo/Pango dependencies required by
canvaswhen a prebuilt binary is unavailable - The
gpiodpackage for the optional GPIO button/LED; GPIO is only initialized whenBUTTON_GPIO_PINis set
On Debian/Raspberry Pi OS, Canvas can be built with:
sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-devFor the optional GPIO button and LED, install the libgpiod 2.x command-line tools and give the service user GPIO access:
sudo apt-get install gpiod
sudo usermod -aG gpio "$USER"Log out and back in after changing group membership. The application uses libgpiod's GPIO character-device interface, which works on Raspberry Pi 5 and replaces the deprecated sysfs GPIO interface.
nvm install
nvm use
pnpm install --frozen-lockfile
cp .env.example .env
pnpm run devpnpm run dev renders whichever edition is next and leaves the PNG in output/. BBC News, XKCD, and Rabbit Hole are enabled by default because they need no credentials; each can be disabled independently for offline development.
For production, set DEV_MODE=false, configure SERIAL_PORT, and run:
pnpm startOn a Raspberry Pi with Node 24 selected through nvm, install or update the boot service as the normal service user:
./scripts/install-service.shThe idempotent installer builds the project, installs local-printer.service, enables it at boot, and configures automatic restart after failures. It requests sudo only for systemd and log installation. Application output is written to /var/log/local-printer/local-printer.log; logrotate rotates it daily or at 5 MB, compresses old logs, and retains seven rotations for no more than 14 days. Follow output with tail -F /var/log/local-printer/local-printer.log.
pnpm start builds TypeScript before launching dist/index.js. Production startup does not print immediately unless PRINT_ON_START=true. Scheduled and button-triggered editions continue running in the same process.
All options are documented in .env.example. Boolean values are parsed strictly (true, false, 1, 0, yes, no, on, or off) so a string such as "false" cannot accidentally enable a feature.
Plugins are enabled only when their required credentials are present:
- BBC News is enabled by default and reads the official BBC News RSS feed directly, without an API key.
- The separate NewsAPI plugin is disabled by default. Set
NEWS_ENABLED=truewithNEWSAPI_TOKENand exactly one ofNEWS_SOURCESorNEWS_COUNTRY. - Telegram accepts only numeric user IDs listed in
TG_ALLOWED_IDS. It automatically embeds Spotify track links and groups text, photos, and tracks received in the same 30-second debounce window into one update. - Weather uses OpenWeather's free 5 day / 3 hour forecast endpoint.
- Spotify watches a public playlist using client-credentials authentication.
- XKCD uses the official JSON feed, checks up to three unseen comics per edition, and prints only comics within the configured readable aspect-ratio range. Every inspected comic and its validity are retained so it is not selected again.
- Rabbit Hole selects a random article from Wikipedia's summary service, includes a dithered lead image when available, and generates its QR code locally.
- OpenAI generates three daily trivia questions, a short On This Day history item, and an unusual-word dictionary specimen using
gpt-5-nanoby default. - Quote renders
QUOTE_TEXTas large poster typography.
Morning and afternoon editions have separate cron expressions. Each plugin can be assigned to either or both using its *_EDITIONS comma-separated setting. Update editions cannot be scheduled: realtime plugins such as Telegram emit them when live data arrives, and the triggering plugin supplies the update title.
The GPIO button input assumes an active-low switch between the configured pin and GND, with that pin pulled up. It prints the morning edition before BUTTON_AFTERNOON_START and the afternoon edition at or after that local time. A successful manual print suppresses that edition's next scheduled run for the same calendar day, including across process restarts. Set BUTTON_PRINT_TEST_MODE=true to make button presses print a calibration sheet instead; scheduled editions remain unchanged.
Trivia is generated once for the local calendar date during a successful morning print and stored in db.json. The generator rotates across broad subject areas and excludes its recent question history to avoid repetitive school-quiz staples. The afternoon edition reads that exact set and prints its answers without another API call. If no morning set exists, the trivia section is skipped rather than generating answers late.
The legacy Instagram plugin source remains under src/plugins/instagram for historical reference. Its undocumented endpoint is invalid, so the plugin is not registered, exported, or included in production builds.
Printing no longer delegates to CUPS, lp, or an OS printer driver. CsnA2Printer writes the CSN-A2 protocol directly over a 19,200 baud serial connection. It waits for boot, wakes the controller, disables sleep, initializes the printer and heat settings, scales the 500px issue canvas to the printer's 384-dot width, packs monochrome pixels most-significant-bit first, and sends one continuous GS v 0 raster using receive-buffer-limited writes and heat-aware print-time pacing.
The implementation follows the CSN-A2 user manual and was cross-checked against the Adafruit and BinaryWorlds drivers, integrated with serialport. The low-level driver is exported from the package root and from local-printer/csn-a2 for standalone use.
Connect the printer's serial receive line to the host's transmit line and connect ground. Disable the Linux serial console on the selected UART, ensure the service user can access the device (commonly through the dialout group), and use level shifting if your host and printer logic voltages differ. The printer needs its own correctly rated power supply; do not power its heater from a Raspberry Pi GPIO rail.
The defaults use heat settings 11/120/20, density/break settings 10/2, one startup feed line, threshold 153, one raster row per write, and a minimum of 30 ms total transmission/print time per row. Each full-width write is 48 bytes, well inside the controller's 255-byte receive buffer. Editions taller than the GS v 0 4095-row command limit are emitted as consecutive raster segments. The driver raises the effective row time when configured heat and density would require longer, and reports both times in the print log. The CSN_A2_* variables in .env.example allow tuning for a particular mechanism, paper, power supply, or serial adapter. Settings are read when the process starts, so restart local-printer.service after changing .env. Standalone callers can use printer.feed(lines) for an explicit paper feed.
This is a transmit-only protocol path. A successful job means every byte was accepted and drained by the serial layer; it cannot confirm paper presence or that the thermal head physically printed. Failed writes preserve the issue number and plugin namespace state so the job can be retried.
Example systemd unit:
Run pnpm run build after each deployment, then use the compiled entry point in the service:
[Unit]
Description=LocalPrinter
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/LocalPrinter
EnvironmentFile=/home/pi/LocalPrinter/.env
ExecStart=/bin/bash -lc 'source "$HOME/.nvm/nvm.sh" && nvm use --silent && exec node --enable-source-maps dist/index.js'
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetEnable it with sudo systemctl enable --now localprinter.service.
pnpm run check
pnpm auditpnpm run check runs strict TypeScript checking, ESLint, a declaration/source-map build, and the unit/integration test suite. Tests cover configuration, HTTP behavior, namespaced storage, edition scheduling, PNG dithering, protocol encoding, a simulated CSN-A2 controller, write chunking/flow control, and the complete byte stream through SerialPortMock. CI runs the same command on Node 24.
Plugins extend Plugin and implement fetch(), hasContent/hasFreshContent, and render(). Rendering returns an array of blocks; each block's render() method returns { endPosY }. See src/plugins/quote/index.ts for the smallest example and src/plugins/spotify/index.ts for a stateful one.
State is stored atomically in db.json. Each plugin owns an isolated JSON namespace and commits it only after rendering and serial transmission both succeed. Realtime Telegram updates intentionally do not advance the scheduled-edition issue number. The versioned namespace format is deliberately incompatible with the legacy database; remove or replace an old db.json before first startup.